|
|
@@ -118,7 +118,6 @@ grpc::Status DatabaseGrpcImpl::gate(const grpc::ServerContext* context,
|
|
|
out = smartbotic::database::Decision{};
|
|
|
return grpc::Status::OK;
|
|
|
}
|
|
|
-
|
|
|
smartbotic::database::ProjectCollection pc;
|
|
|
try {
|
|
|
pc = smartbotic::database::parseProjectCollection(qualified);
|
|
|
@@ -163,6 +162,85 @@ grpc::Status DatabaseGrpcImpl::gateFile(const grpc::ServerContext* context,
|
|
|
return grpc::Status::OK;
|
|
|
}
|
|
|
|
|
|
+bool DatabaseGrpcImpl::handlePolicyWrite(const grpc::ServerContext* context,
|
|
|
+ const std::string& collection,
|
|
|
+ const std::string& id,
|
|
|
+ const nlohmann::json* data,
|
|
|
+ bool is_delete,
|
|
|
+ grpc::Status& statusOut) {
|
|
|
+ // `collection` arrives qualified; the policy collection is global.
|
|
|
+ const auto sep = collection.find(':');
|
|
|
+ const std::string bare = sep == std::string::npos ? collection
|
|
|
+ : collection.substr(sep + 1);
|
|
|
+ if (bare != PolicyManager::SYSTEM_COLLECTION &&
|
|
|
+ collection != PolicyManager::SYSTEM_COLLECTION) {
|
|
|
+ return false; // not a policy write
|
|
|
+ }
|
|
|
+
|
|
|
+ // Policy ids are "<project>:<principal>" or "<project>:__security__".
|
|
|
+ const auto idsep = id.find(':');
|
|
|
+ if (idsep == std::string::npos) {
|
|
|
+ statusOut = grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
|
|
+ "policy id must be '<project>:<principal>'");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ const std::string project = id.substr(0, idsep);
|
|
|
+ const std::string tail = id.substr(idsep + 1);
|
|
|
+
|
|
|
+ const std::string principal = smartbotic::database::auth::principalOf(context);
|
|
|
+ if (!policy_manager_.mayAdministerNow(project, principal)) {
|
|
|
+ spdlog::warn("policy: DENIED principal '{}' management of project '{}'",
|
|
|
+ principal, project);
|
|
|
+ statusOut = grpc::Status(grpc::StatusCode::PERMISSION_DENIED, "access denied");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string err;
|
|
|
+ bool ok = false;
|
|
|
+ if (tail == PolicyManager::SECURITY_DOC) {
|
|
|
+ if (is_delete) {
|
|
|
+ smartbotic::database::ProjectSecurity off;
|
|
|
+ ok = policy_manager_.setSecurity(project, off, err);
|
|
|
+ } else {
|
|
|
+ smartbotic::database::ProjectSecurity sec;
|
|
|
+ sec.enabled = data && data->value("enabled", false);
|
|
|
+ sec.mode = (data && data->value("mode", std::string("enforce")) == "audit")
|
|
|
+ ? smartbotic::database::SecurityMode::Audit
|
|
|
+ : smartbotic::database::SecurityMode::Enforce;
|
|
|
+ // Routed through setSecurity so the no-admin and last-admin guards
|
|
|
+ // apply. A raw insert here would defeat them.
|
|
|
+ ok = policy_manager_.setSecurity(project, sec, err);
|
|
|
+ }
|
|
|
+ } else if (is_delete) {
|
|
|
+ ok = policy_manager_.removePolicy(project, tail, err);
|
|
|
+ } else {
|
|
|
+ if (!data) {
|
|
|
+ statusOut = grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
|
|
+ "policy body required");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ nlohmann::json body = *data;
|
|
|
+ body["principal"] = tail;
|
|
|
+ ok = policy_manager_.setPolicyFromJson(project, body, err);
|
|
|
+ }
|
|
|
+
|
|
|
+ statusOut = ok ? grpc::Status::OK
|
|
|
+ : grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, err);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+grpc::Status DatabaseGrpcImpl::requireAnyAdmin(const grpc::ServerContext* context,
|
|
|
+ const char* operation) const {
|
|
|
+ if (!policy_manager_.anyProjectSecured()) return grpc::Status::OK;
|
|
|
+ const std::string principal = smartbotic::database::auth::principalOf(context);
|
|
|
+ // mayAdminister returns true for an unsecured project, so ask about the
|
|
|
+ // secured ones only: being admin of ANY secured project is the bar.
|
|
|
+ if (policy_manager_.isAdminSomewhere(principal)) return grpc::Status::OK;
|
|
|
+ spdlog::warn("policy: DENIED principal '{}' service-wide operation '{}' "
|
|
|
+ "(requires admin of some secured project)", principal, operation);
|
|
|
+ return grpc::Status(grpc::StatusCode::PERMISSION_DENIED, "access denied");
|
|
|
+}
|
|
|
+
|
|
|
void DatabaseGrpcImpl::applyMask(smartbotic::database::Document& doc,
|
|
|
const std::vector<std::string>& mask) {
|
|
|
if (mask.empty()) return;
|
|
|
@@ -173,6 +251,33 @@ void DatabaseGrpcImpl::applyMask(smartbotic::database::Document& doc,
|
|
|
doc.set_data(data);
|
|
|
}
|
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::rejectMaskedWrite(
|
|
|
+ const nlohmann::json& data,
|
|
|
+ const std::vector<std::string>& mask) {
|
|
|
+ if (mask.empty() || !data.is_object()) return grpc::Status::OK;
|
|
|
+ for (const auto& m : mask) {
|
|
|
+ // Walk the dot path; if it resolves to anything present in the body,
|
|
|
+ // the write touches a field the caller cannot read.
|
|
|
+ const nlohmann::json* cur = &data;
|
|
|
+ size_t pos = 0;
|
|
|
+ bool present = true;
|
|
|
+ while (pos <= m.size()) {
|
|
|
+ const size_t dot = m.find('.', pos);
|
|
|
+ const std::string seg =
|
|
|
+ m.substr(pos, dot == std::string::npos ? std::string::npos : dot - pos);
|
|
|
+ if (!cur->is_object() || !cur->contains(seg)) { present = false; break; }
|
|
|
+ cur = &(*cur)[seg];
|
|
|
+ if (dot == std::string::npos) break;
|
|
|
+ pos = dot + 1;
|
|
|
+ }
|
|
|
+ if (present) {
|
|
|
+ return grpc::Status(grpc::StatusCode::PERMISSION_DENIED,
|
|
|
+ "writing field '" + m + "' is not permitted");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return grpc::Status::OK;
|
|
|
+}
|
|
|
+
|
|
|
grpc::Status DatabaseGrpcImpl::rejectMaskedFilters(
|
|
|
const std::vector<smartbotic::database::Filter>& filters,
|
|
|
const std::vector<std::string>& mask) {
|
|
|
@@ -195,6 +300,12 @@ grpc::Status DatabaseGrpcImpl::Insert(
|
|
|
const pb::InsertRequest* request,
|
|
|
pb::InsertResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("Insert", service_);
|
|
|
}
|
|
|
@@ -207,6 +318,19 @@ grpc::Status DatabaseGrpcImpl::Insert(
|
|
|
}
|
|
|
try {
|
|
|
nlohmann::json data = smartbotic::db::parse_to_nlohmann(request->data());
|
|
|
+ if (auto st = rejectMaskedWrite(data, dec.mask); !st.ok()) return st;
|
|
|
+
|
|
|
+ // v2.8.0 — a write to `_policies` is policy management, not a document
|
|
|
+ // insert: it must refresh the cache and honour the lockout guards.
|
|
|
+ {
|
|
|
+ grpc::Status pst;
|
|
|
+ if (handlePolicyWrite(context, request->collection(), request->id(),
|
|
|
+ &data, /*is_delete=*/false, pst)) {
|
|
|
+ if (!pst.ok()) return pst;
|
|
|
+ response->set_id(request->id());
|
|
|
+ return grpc::Status::OK;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
Document doc;
|
|
|
doc.id = request->id();
|
|
|
@@ -311,6 +435,16 @@ grpc::Status DatabaseGrpcImpl::Get(
|
|
|
// Decrypt sensitive fields
|
|
|
encryption_.decryptSensitiveFields(*doc);
|
|
|
|
|
|
+ // v2.8.0 — the policy row predicate can hide the document outright. Same
|
|
|
+ // treatment as a view's where below: not-found, never "exists but
|
|
|
+ // forbidden", since that distinction is itself information.
|
|
|
+ if (!dec.row.empty() && !store_.matchesFilters(*doc, dec.row)) {
|
|
|
+ response->set_found(false);
|
|
|
+ return grpc::Status::OK;
|
|
|
+ }
|
|
|
+ // Remove masked columns before anything is serialised out.
|
|
|
+ applyMask(*doc, dec.mask);
|
|
|
+
|
|
|
// Apply view's where filters — if doc doesn't match, treat as not found
|
|
|
if (view && !view->where.empty()) {
|
|
|
if (!store_.matchesFilters(*doc, view->where)) {
|
|
|
@@ -341,6 +475,12 @@ grpc::Status DatabaseGrpcImpl::Update(
|
|
|
const pb::UpdateRequest* request,
|
|
|
pb::UpdateResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -424,6 +564,12 @@ grpc::Status DatabaseGrpcImpl::PatchDocument(
|
|
|
const pb::PatchDocumentRequest* request,
|
|
|
pb::PatchDocumentResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -479,10 +625,16 @@ grpc::Status DatabaseGrpcImpl::PatchDocument(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::Upsert(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::UpsertRequest* request,
|
|
|
pb::UpsertResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Upsert writes; gated exactly like Insert.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("Upsert", service_);
|
|
|
}
|
|
|
@@ -495,6 +647,21 @@ grpc::Status DatabaseGrpcImpl::Upsert(
|
|
|
}
|
|
|
try {
|
|
|
nlohmann::json data = smartbotic::db::parse_to_nlohmann(request->data());
|
|
|
+ if (auto st = rejectMaskedWrite(data, dec.mask); !st.ok()) return st;
|
|
|
+
|
|
|
+ // v2.8.0 — Upsert is a write path too. Policy management must be
|
|
|
+ // intercepted on EVERY write path or an edit silently lands as a plain
|
|
|
+ // document: the guards are skipped and the cache is never refreshed, so
|
|
|
+ // the policy appears to be stored and has no effect.
|
|
|
+ {
|
|
|
+ grpc::Status pst;
|
|
|
+ if (handlePolicyWrite(context, request->collection(), request->id(),
|
|
|
+ &data, /*is_delete=*/false, pst)) {
|
|
|
+ if (!pst.ok()) return pst;
|
|
|
+ response->set_id(request->id());
|
|
|
+ return grpc::Status::OK;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
bool existed = store_.exists(request->collection(), request->id());
|
|
|
|
|
|
@@ -558,6 +725,24 @@ grpc::Status DatabaseGrpcImpl::Delete(
|
|
|
const pb::DeleteRequest* request,
|
|
|
pb::DeleteResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — a delete on `_policies` is policy management. Handled before the
|
|
|
+ // ordinary gate so the last-admin guard applies.
|
|
|
+ {
|
|
|
+ grpc::Status pst;
|
|
|
+ if (handlePolicyWrite(context, request->collection(), request->id(),
|
|
|
+ nullptr, /*is_delete=*/true, pst)) {
|
|
|
+ if (!pst.ok()) return pst;
|
|
|
+ response->set_deleted(true);
|
|
|
+ return grpc::Status::OK;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("Delete", service_);
|
|
|
}
|
|
|
@@ -590,6 +775,12 @@ grpc::Status DatabaseGrpcImpl::Exists(
|
|
|
const pb::ExistsRequest* request,
|
|
|
pb::ExistsResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Checked before anything else, so an unauthorised caller learns nothing about server state.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
// v2.0 Stage 4 — LMDB-first read. Gated on mirror_healthy_ + zero
|
|
|
// drift + non-system collection + doc_store_ available. The
|
|
|
// defensive MemoryStore-on-LMDB-miss fallback was removed once the
|
|
|
@@ -632,6 +823,12 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
|
|
|
const pb::GetVersionHistoryRequest* request,
|
|
|
pb::GetVersionHistoryResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. History returns previous document bodies; ungated it would bypass column masks.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto result = store_.getVersionHistory(
|
|
|
request->collection(), request->id(),
|
|
|
request->limit(), request->offset());
|
|
|
@@ -648,10 +845,16 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::GetDocumentVersionRequest* request,
|
|
|
pb::GetDocumentVersionResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Checked before anything else, so an unauthorised caller learns nothing about server state.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto ver = store_.getDocumentAtVersion(
|
|
|
request->collection(), request->id(), request->version());
|
|
|
|
|
|
@@ -667,10 +870,16 @@ grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::RestoreVersion(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::RestoreVersionRequest* request,
|
|
|
pb::RestoreVersionResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -698,10 +907,16 @@ grpc::Status DatabaseGrpcImpl::RestoreVersion(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::RestoreToDate(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::RestoreToDateRequest* request,
|
|
|
pb::RestoreToDateResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Write access is gated separately from read: read never implies write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -732,10 +947,16 @@ grpc::Status DatabaseGrpcImpl::RestoreToDate(
|
|
|
// ===== Batch Operations =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::BatchInsert(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::BatchInsertRequest* request,
|
|
|
pb::BatchInsertResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. A batch is still a write - gated once for the whole batch, which shares one collection.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("BatchInsert", service_);
|
|
|
}
|
|
|
@@ -785,10 +1006,16 @@ grpc::Status DatabaseGrpcImpl::BatchInsert(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::BatchGet(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::BatchGetRequest* request,
|
|
|
pb::BatchGetResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Batch reads must mask and filter like Get.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
for (const auto& id : request->ids()) {
|
|
|
auto doc = store_.get(request->collection(), id);
|
|
|
if (doc) {
|
|
|
@@ -801,10 +1028,16 @@ grpc::Status DatabaseGrpcImpl::BatchGet(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::BatchDelete(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::BatchDeleteRequest* request,
|
|
|
pb::BatchDeleteResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Batch delete is a write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("BatchDelete", service_);
|
|
|
}
|
|
|
@@ -864,6 +1097,20 @@ grpc::Status DatabaseGrpcImpl::Find(
|
|
|
// construction: Insert ACK is gated on the dual-write mirror commit,
|
|
|
// so any ACK'd write is visible to subsequent LMDB scans.
|
|
|
// v2.3 — parse for project routing (same shape as Get / Exists).
|
|
|
+ // v2.8.0 — access gate. After view resolution so policy applies to the
|
|
|
+ // UNDERLYING collection: a view must not become a way around a rule.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, targetCollection, smartbotic::database::Access::Read, dec);
|
|
|
+ !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+ // A filter naming a masked path is refused: `salary > 100000` would leak a
|
|
|
+ // value the caller is not permitted to read.
|
|
|
+ if (auto st = rejectMaskedFilters(query.filters, dec.mask); !st.ok()) return st;
|
|
|
+ // Row predicate AND-merges with the caller's filters, exactly as a view's
|
|
|
+ // `where` already does.
|
|
|
+ for (const auto& f : dec.row) query.filters.push_back(f);
|
|
|
+
|
|
|
QueryResult result;
|
|
|
bool used_lmdb = false;
|
|
|
if (!targetCollection.empty() && targetCollection[0] != '_'
|
|
|
@@ -901,6 +1148,9 @@ grpc::Status DatabaseGrpcImpl::Find(
|
|
|
|
|
|
for (auto& doc : result.documents) {
|
|
|
encryption_.decryptSensitiveFields(doc);
|
|
|
+ // v2.8.0 — mask before the view projection runs, so a view cannot
|
|
|
+ // re-expose a column that policy removed.
|
|
|
+ applyMask(doc, dec.mask);
|
|
|
pb::Document protoDoc = toProto(doc);
|
|
|
if (view) {
|
|
|
nlohmann::json docJson = smartbotic::db::parse_to_nlohmann(protoDoc.data());
|
|
|
@@ -998,6 +1248,12 @@ grpc::Status DatabaseGrpcImpl::SimilaritySearch(
|
|
|
const pb::SimilaritySearchRequest* request,
|
|
|
pb::SimilaritySearchResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Checked before anything else, so an unauthorised caller learns nothing about server state.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
if (request->collection().empty()) {
|
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Collection name is required");
|
|
|
@@ -1174,10 +1430,16 @@ grpc::Status DatabaseGrpcImpl::SimilaritySearch(
|
|
|
// ===== Set Operations =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::SetAdd(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::SetAddRequest* request,
|
|
|
pb::SetAddResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Set operations mutate collection data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("SetAdd", service_);
|
|
|
}
|
|
|
@@ -1190,10 +1452,16 @@ grpc::Status DatabaseGrpcImpl::SetAdd(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::SetRemove(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::SetRemoveRequest* request,
|
|
|
pb::SetRemoveResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Set operations mutate collection data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("SetRemove", service_);
|
|
|
}
|
|
|
@@ -1206,10 +1474,16 @@ grpc::Status DatabaseGrpcImpl::SetRemove(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::SetMembers(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::SetMembersRequest* request,
|
|
|
pb::SetMembersResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Set membership is collection data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto members = store_.setMembers(request->collection(), request->set_id());
|
|
|
for (const auto& member : members) {
|
|
|
response->add_members(member);
|
|
|
@@ -1218,10 +1492,16 @@ grpc::Status DatabaseGrpcImpl::SetMembers(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::SetIsMember(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::SetIsMemberRequest* request,
|
|
|
pb::SetIsMemberResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Set membership is collection data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
bool isMember = store_.setIsMember(request->collection(), request->set_id(), request->member());
|
|
|
response->set_is_member(isMember);
|
|
|
return grpc::Status::OK;
|
|
|
@@ -1230,10 +1510,16 @@ grpc::Status DatabaseGrpcImpl::SetIsMember(
|
|
|
// ===== Collection Management =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::CreateCollection(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::CreateCollectionRequest* request,
|
|
|
pb::CreateCollectionResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Creating a collection in a project is a write to that project.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->name(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("CreateCollection", service_);
|
|
|
}
|
|
|
@@ -1264,10 +1550,16 @@ grpc::Status DatabaseGrpcImpl::CreateCollection(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::DropCollection(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::DropCollectionRequest* request,
|
|
|
pb::DropCollectionResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Destructive; must never be reachable without a write grant.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->name(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("DropCollection", service_);
|
|
|
}
|
|
|
@@ -1285,12 +1577,20 @@ grpc::Status DatabaseGrpcImpl::DropCollection(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::ListCollections(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::ListCollectionsRequest* /*request*/,
|
|
|
pb::ListCollectionsResponse* response
|
|
|
) {
|
|
|
auto names = store_.listCollections();
|
|
|
+ // v2.8.0 — filter per entry rather than refusing the call. The mere
|
|
|
+ // existence of a collection is information: an enumeration that shows names
|
|
|
+ // a principal cannot read leaks the shape of the dataset.
|
|
|
for (const auto& name : names) {
|
|
|
+ smartbotic::database::Decision ldec;
|
|
|
+ if (auto st = gate(context, name, smartbotic::database::Access::Read, ldec);
|
|
|
+ !st.ok()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
response->add_names(name);
|
|
|
}
|
|
|
return grpc::Status::OK;
|
|
|
@@ -1301,6 +1601,14 @@ grpc::Status DatabaseGrpcImpl::GetCollectionInfo(
|
|
|
const pb::GetCollectionInfoRequest* request,
|
|
|
pb::GetCollectionInfoResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. GetCollectionInfo names the collection in `name`,
|
|
|
+ // not `collection`. Gated because documentCount and sizeBytes are
|
|
|
+ // information about data the caller may not be permitted to read.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->name(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto info = store_.getCollectionInfo(request->name());
|
|
|
if (!info) {
|
|
|
response->set_found(false);
|
|
|
@@ -1346,22 +1654,37 @@ grpc::Status DatabaseGrpcImpl::GetCollectionInfo(
|
|
|
// without parsing status strings.
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::ListProjects(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::ListProjectsRequest* /*request*/,
|
|
|
pb::ListProjectsResponse* response
|
|
|
) {
|
|
|
auto names = service_.listProjects();
|
|
|
+ // v2.8.0 — a project name is information. Once any project is secured, show
|
|
|
+ // a caller only the projects it has some grant in; unsecured projects stay
|
|
|
+ // visible to everyone, matching pre-2.8.0 behaviour.
|
|
|
+ const bool secured = policy_manager_.anyProjectSecured();
|
|
|
+ const std::string principal =
|
|
|
+ secured ? smartbotic::database::auth::principalOf(context) : std::string();
|
|
|
for (auto& name : names) {
|
|
|
+ if (secured) {
|
|
|
+ const auto sec = policy_manager_.securityOf(name);
|
|
|
+ if (sec.enabled && !policy_manager_.getPolicy(name, principal).has_value()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
response->add_projects(std::move(name));
|
|
|
}
|
|
|
return grpc::Status::OK;
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::CreateProject(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::CreateProjectRequest* request,
|
|
|
pb::CreateProjectResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — service-wide/cross-project: no single project to gate on.
|
|
|
+ if (auto st = requireAnyAdmin(context, "CreateProject"); !st.ok()) return st;
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("CreateProject", service_);
|
|
|
}
|
|
|
@@ -1375,10 +1698,13 @@ grpc::Status DatabaseGrpcImpl::CreateProject(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::DropProject(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::DropProjectRequest* request,
|
|
|
pb::DropProjectResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — service-wide/cross-project: no single project to gate on.
|
|
|
+ if (auto st = requireAnyAdmin(context, "DropProject"); !st.ok()) return st;
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
return readOnlyStatus("DropProject", service_);
|
|
|
}
|
|
|
@@ -1433,6 +1759,15 @@ grpc::Status DatabaseGrpcImpl::UploadFile(
|
|
|
info.isPublic = metadata.is_public();
|
|
|
// v2.6.0 — empty project means the default namespace.
|
|
|
info.project = metadata.project().empty() ? "default" : metadata.project();
|
|
|
+ {
|
|
|
+ // v2.8.0 — gate the upload on the declared file type.
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
+ if (auto st = gateFile(context, info.project, info.fileType,
|
|
|
+ smartbotic::database::Access::Write, fdec);
|
|
|
+ !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+ }
|
|
|
for (const auto& [key, value] : metadata.metadata()) {
|
|
|
info.metadata[key] = value;
|
|
|
}
|
|
|
@@ -1471,6 +1806,17 @@ grpc::Status DatabaseGrpcImpl::DownloadFile(
|
|
|
if (!info) {
|
|
|
return grpc::Status(grpc::StatusCode::NOT_FOUND, "File not found");
|
|
|
}
|
|
|
+ // v2.8.0 — gate on the record's file type, which is only known after
|
|
|
+ // the lookup. A denial is reported as NOT_FOUND, matching the
|
|
|
+ // cross-project rule: "exists but forbidden" is itself information.
|
|
|
+ {
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
+ if (auto st = gateFile(context, info->project, info->fileType,
|
|
|
+ smartbotic::database::Access::Read, fdec);
|
|
|
+ !st.ok()) {
|
|
|
+ return grpc::Status(grpc::StatusCode::NOT_FOUND, "File not found");
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
// Send metadata first
|
|
|
pb::FileChunk metadataChunk;
|
|
|
@@ -1515,6 +1861,18 @@ grpc::Status DatabaseGrpcImpl::DeleteFile(
|
|
|
if (store_.pressure() == MemoryPressure::Emergency) {
|
|
|
return memoryEmergencyStatus("DeleteFile", store_);
|
|
|
}
|
|
|
+ // v2.8.0 — gate the delete on the record's file type. Resolve first so we
|
|
|
+ // know the type; report a denial as "not deleted" rather than distinguishing
|
|
|
+ // it from a missing file.
|
|
|
+ if (auto info = files_.getFileInfoIn(request->project(), request->id())) {
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
+ if (auto st = gateFile(context, info->project, info->fileType,
|
|
|
+ smartbotic::database::Access::Write, fdec);
|
|
|
+ !st.ok()) {
|
|
|
+ response->set_deleted(false);
|
|
|
+ return grpc::Status::OK;
|
|
|
+ }
|
|
|
+ }
|
|
|
// v2.6.0 — scoped by project; a cross-project id deletes nothing.
|
|
|
bool deleted = files_.deleteFileIn(request->project(), request->id());
|
|
|
response->set_deleted(deleted);
|
|
|
@@ -1530,6 +1888,16 @@ grpc::Status DatabaseGrpcImpl::GetFileInfo(
|
|
|
if (!info) {
|
|
|
return grpc::Status(grpc::StatusCode::NOT_FOUND, "File not found");
|
|
|
}
|
|
|
+ {
|
|
|
+ // v2.8.0 — same shape as DownloadFile: gate on the resolved file type,
|
|
|
+ // report a denial as NOT_FOUND.
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
+ if (auto st = gateFile(context, info->project, info->fileType,
|
|
|
+ smartbotic::database::Access::Read, fdec);
|
|
|
+ !st.ok()) {
|
|
|
+ return grpc::Status(grpc::StatusCode::NOT_FOUND, "File not found");
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
response->set_project(info->project);
|
|
|
response->set_id(info->id);
|
|
|
@@ -1562,10 +1930,20 @@ grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
|
request->checksum(), request->name()
|
|
|
);
|
|
|
|
|
|
- response->set_total_count(result.totalCount);
|
|
|
response->set_has_more(result.hasMore);
|
|
|
|
|
|
for (const auto& info : result.files) {
|
|
|
+ // v2.8.0 — filter per entry rather than refusing the whole listing, so a
|
|
|
+ // principal granted one file type still gets a usable result. total_count
|
|
|
+ // is corrected below.
|
|
|
+ {
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
+ if (auto st = gateFile(context, info.project, info.fileType,
|
|
|
+ smartbotic::database::Access::Read, fdec);
|
|
|
+ !st.ok()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
auto* file = response->add_files();
|
|
|
file->set_id(info.id);
|
|
|
file->set_name(info.name);
|
|
|
@@ -1579,6 +1957,10 @@ grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
|
file->set_project(info.project);
|
|
|
}
|
|
|
|
|
|
+ // v2.8.0 — total_count must reflect what the caller may actually see, not
|
|
|
+ // what the store holds. Reporting the unfiltered total would leak the number
|
|
|
+ // of files in file types this principal has no grant for.
|
|
|
+ response->set_total_count(static_cast<uint64_t>(response->files_size()));
|
|
|
return grpc::Status::OK;
|
|
|
}
|
|
|
|
|
|
@@ -1600,9 +1982,24 @@ grpc::Status DatabaseGrpcImpl::Subscribe(
|
|
|
std::vector<std::string> patterns(request->patterns().begin(), request->patterns().end());
|
|
|
bool includeData = request->include_data();
|
|
|
|
|
|
- uint64_t subId = events_.subscribe(collections, patterns, [writer, includeData, context](const DatabaseEvent& event) {
|
|
|
+ // v2.8.0 — Subscribe is gated PER EVENT, not once at entry. An empty
|
|
|
+ // `collections` list means "every collection" and `patterns` accepts globs,
|
|
|
+ // so there is no single name to authorise up front. Filtering in the
|
|
|
+ // callback is also what makes a partial grant work: a principal subscribed
|
|
|
+ // to everything receives only the collections it may read.
|
|
|
+ uint64_t subId = events_.subscribe(collections, patterns,
|
|
|
+ [this, writer, includeData, context](const DatabaseEvent& event) {
|
|
|
if (context->IsCancelled()) return;
|
|
|
|
|
|
+ smartbotic::database::Decision edec;
|
|
|
+ if (auto st = gate(context, event.collection,
|
|
|
+ smartbotic::database::Access::Read, edec);
|
|
|
+ !st.ok()) {
|
|
|
+ // Silently skip. Emitting an error here would confirm that a
|
|
|
+ // collection the caller cannot read just changed.
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
pb::DatabaseEvent protoEvent;
|
|
|
protoEvent.set_type(static_cast<pb::EventType>(static_cast<int>(event.type) + 1));
|
|
|
protoEvent.set_collection(event.collection);
|
|
|
@@ -1611,8 +2008,15 @@ grpc::Status DatabaseGrpcImpl::Subscribe(
|
|
|
protoEvent.set_node_id(event.nodeId);
|
|
|
|
|
|
if (includeData && event.data) {
|
|
|
- std::string dataStr = event.data->dump();
|
|
|
- protoEvent.set_data(dataStr);
|
|
|
+ // Masked columns must not ride out through the event stream - that
|
|
|
+ // would be the simplest possible way around a column mask.
|
|
|
+ if (edec.mask.empty()) {
|
|
|
+ protoEvent.set_data(event.data->dump());
|
|
|
+ } else {
|
|
|
+ nlohmann::json masked =
|
|
|
+ applyProjection(*event.data, /*include=*/{}, /*exclude=*/edec.mask);
|
|
|
+ protoEvent.set_data(masked.dump());
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
writer->Write(protoEvent);
|
|
|
@@ -1652,10 +2056,13 @@ grpc::Status DatabaseGrpcImpl::HealthCheck(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::GetStats(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::GetStatsRequest* /*request*/,
|
|
|
pb::GetStatsResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — service-wide/cross-project: no single project to gate on.
|
|
|
+ if (auto st = requireAnyAdmin(context, "GetStats"); !st.ok()) return st;
|
|
|
+
|
|
|
auto stats = store_.getStats();
|
|
|
auto persistStats = persistence_.getStats();
|
|
|
const auto& config = store_.getConfig();
|
|
|
@@ -1697,7 +2104,7 @@ grpc::Status DatabaseGrpcImpl::GetStats(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::GetMemoryStats(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::GetMemoryStatsRequest* /*request*/,
|
|
|
pb::GetMemoryStatsResponse* response
|
|
|
) {
|
|
|
@@ -1723,6 +2130,17 @@ grpc::Status DatabaseGrpcImpl::GetMemoryStats(
|
|
|
};
|
|
|
|
|
|
for (const auto& c : snap.collections) {
|
|
|
+ // v2.8.0 — per-collection document counts and byte sizes are
|
|
|
+ // information about data the caller may not read, so the same filter
|
|
|
+ // applies here as to ListCollections.
|
|
|
+ {
|
|
|
+ smartbotic::database::Decision sdec;
|
|
|
+ if (auto st = gate(context, c.collection,
|
|
|
+ smartbotic::database::Access::Read, sdec);
|
|
|
+ !st.ok()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
auto* pbc = response->add_collections();
|
|
|
pbc->set_collection(c.collection);
|
|
|
pbc->set_document_count(c.documentCount);
|
|
|
@@ -1838,10 +2256,16 @@ Query DatabaseGrpcImpl::fromProtoQuery(const pb::FindRequest& request) {
|
|
|
// ===== View Operations =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::CreateView(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::CreateViewRequest* request,
|
|
|
pb::CreateViewResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. A view over a collection you cannot write is a schema write to that project.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -1894,10 +2318,16 @@ grpc::Status DatabaseGrpcImpl::CreateView(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::DropView(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::DropViewRequest* request,
|
|
|
pb::DropViewResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Dropping a view is a schema write.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->name(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -1921,7 +2351,7 @@ grpc::Status DatabaseGrpcImpl::DropView(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::ListViews(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::ListViewsRequest* request,
|
|
|
pb::ListViewsResponse* response
|
|
|
) {
|
|
|
@@ -1934,6 +2364,16 @@ grpc::Status DatabaseGrpcImpl::ListViews(
|
|
|
&& smartbotic::database::resolveCollection(v.name).project != wantProject) {
|
|
|
continue;
|
|
|
}
|
|
|
+ // v2.8.0 — a view is a window onto a collection, so listing it leaks
|
|
|
+ // that collection's existence and shape. Filter per entry.
|
|
|
+ {
|
|
|
+ smartbotic::database::Decision vdec;
|
|
|
+ if (auto st = gate(context, v.collection,
|
|
|
+ smartbotic::database::Access::Read, vdec);
|
|
|
+ !st.ok()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
auto* out = response->add_views();
|
|
|
out->set_name(v.name);
|
|
|
out->set_collection(v.collection);
|
|
|
@@ -1957,10 +2397,16 @@ grpc::Status DatabaseGrpcImpl::ListViews(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::GetViewInfo(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::GetViewInfoRequest* request,
|
|
|
pb::GetViewInfoResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. A view definition describes a collection's shape.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->name(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto v = view_manager_.getView(request->name());
|
|
|
if (!v) {
|
|
|
response->set_found(false);
|
|
|
@@ -2114,10 +2560,16 @@ grpc::Status DatabaseReplicationGrpcImpl::GetNodeState(
|
|
|
// ===== Collection Config Operations =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::ConfigureCollection(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::ConfigureCollectionRequest* request,
|
|
|
pb::ConfigureCollectionResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Config changes are writes - versioning and precision affect stored data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -2162,10 +2614,16 @@ grpc::Status DatabaseGrpcImpl::ConfigureCollection(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::GetCollectionConfig(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::GetCollectionConfigRequest* request,
|
|
|
pb::GetCollectionConfigResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Collection config describes stored data.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
auto cfg = config_manager_.configFor(request->collection());
|
|
|
response->mutable_config()->set_timestamp_precision(cfg.timestampPrecision);
|
|
|
response->mutable_config()->set_versioning_enabled(cfg.versioningEnabled);
|
|
|
@@ -2174,10 +2632,16 @@ grpc::Status DatabaseGrpcImpl::GetCollectionConfig(
|
|
|
}
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::MigrateCollectionTimestamps(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::MigrateCollectionTimestampsRequest* request,
|
|
|
pb::MigrateCollectionTimestampsResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — access gate. Rewrites every document's timestamps.
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
+ if (auto st = gate(context, request->collection(), smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
+ return st;
|
|
|
+ }
|
|
|
+
|
|
|
if (service_.isReadOnly()) {
|
|
|
response->set_success(false);
|
|
|
response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
@@ -2278,10 +2742,13 @@ grpc::Status DatabaseGrpcImpl::MigrateCollectionTimestamps(
|
|
|
// ===== Read-Only Control =====
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::SetReadOnly(
|
|
|
- grpc::ServerContext* /*context*/,
|
|
|
+ grpc::ServerContext* context,
|
|
|
const pb::SetReadOnlyRequest* request,
|
|
|
pb::SetReadOnlyResponse* response
|
|
|
) {
|
|
|
+ // v2.8.0 — service-wide/cross-project: no single project to gate on.
|
|
|
+ if (auto st = requireAnyAdmin(context, "SetReadOnly"); !st.ok()) return st;
|
|
|
+
|
|
|
bool was_readonly = service_.isReadOnly();
|
|
|
std::string reason = request->read_only()
|
|
|
? "manually locked via SetReadOnly RPC"
|