|
@@ -108,6 +108,21 @@ DatabaseGrpcImpl::DatabaseGrpcImpl(
|
|
|
// function rather than a check per handler.
|
|
// function rather than a check per handler.
|
|
|
// -------------------------------------------------------------------------
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// v2.8.0 — translate a request's optional ttl_seconds into an absolute expiry
|
|
|
|
|
+// override. Absent means "leave the stored expiry alone"; 0 means "clear it";
|
|
|
|
|
+// N means "N seconds from now". Presence is what makes the three cases
|
|
|
|
|
+// distinguishable, which a plain uint32 could not do.
|
|
|
|
|
+template <typename Req>
|
|
|
|
|
+static std::optional<uint64_t> expiryOverrideFrom(const Req& request) {
|
|
|
|
|
+ if (!request.has_ttl_seconds()) return std::nullopt;
|
|
|
|
|
+ if (request.ttl_seconds() == 0) return uint64_t{0};
|
|
|
|
|
+ const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
|
|
|
+ std::chrono::system_clock::now().time_since_epoch()).count();
|
|
|
|
|
+ return static_cast<uint64_t>(now) +
|
|
|
|
|
+ static_cast<uint64_t>(request.ttl_seconds()) * 1000ULL;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
grpc::Status DatabaseGrpcImpl::gate(const grpc::ServerContext* context,
|
|
grpc::Status DatabaseGrpcImpl::gate(const grpc::ServerContext* context,
|
|
|
const std::string& qualified,
|
|
const std::string& qualified,
|
|
|
smartbotic::database::Access access,
|
|
smartbotic::database::Access access,
|
|
@@ -544,7 +559,8 @@ grpc::Status DatabaseGrpcImpl::Update(
|
|
|
request->collection(),
|
|
request->collection(),
|
|
|
request->id(),
|
|
request->id(),
|
|
|
doc,
|
|
doc,
|
|
|
- request->expected_version()
|
|
|
|
|
|
|
+ request->expected_version(),
|
|
|
|
|
+ expiryOverrideFrom(*request)
|
|
|
);
|
|
);
|
|
|
if (!success) {
|
|
if (!success) {
|
|
|
response->set_success(false);
|
|
response->set_success(false);
|
|
@@ -621,7 +637,8 @@ grpc::Status DatabaseGrpcImpl::PatchDocument(
|
|
|
request->collection(),
|
|
request->collection(),
|
|
|
request->id(),
|
|
request->id(),
|
|
|
patch,
|
|
patch,
|
|
|
- request->actor()
|
|
|
|
|
|
|
+ request->actor(),
|
|
|
|
|
+ expiryOverrideFrom(*request)
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
if (newVersion == 0) {
|
|
if (newVersion == 0) {
|
|
@@ -1609,14 +1626,28 @@ grpc::Status DatabaseGrpcImpl::DropCollection(
|
|
|
|
|
|
|
|
grpc::Status DatabaseGrpcImpl::ListCollections(
|
|
grpc::Status DatabaseGrpcImpl::ListCollections(
|
|
|
grpc::ServerContext* context,
|
|
grpc::ServerContext* context,
|
|
|
- const pb::ListCollectionsRequest* /*request*/,
|
|
|
|
|
|
|
+ const pb::ListCollectionsRequest* request,
|
|
|
pb::ListCollectionsResponse* response
|
|
pb::ListCollectionsResponse* response
|
|
|
) {
|
|
) {
|
|
|
auto names = store_.listCollections();
|
|
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.
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // v2.8.0 — restrict to one project when the caller names one, mirroring
|
|
|
|
|
+ // ListViews. Empty means every project, which is the operator/CLI case.
|
|
|
|
|
+ const std::string& wantProject = request->project();
|
|
|
|
|
+
|
|
|
|
|
+ // 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) {
|
|
for (const auto& name : names) {
|
|
|
|
|
+ if (!wantProject.empty()) {
|
|
|
|
|
+ std::string owner;
|
|
|
|
|
+ try {
|
|
|
|
|
+ owner = smartbotic::database::resolveCollection(name).project;
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ continue; // unparseable name cannot be attributed; omit it
|
|
|
|
|
+ }
|
|
|
|
|
+ if (owner != wantProject) continue;
|
|
|
|
|
+ }
|
|
|
smartbotic::database::Decision ldec;
|
|
smartbotic::database::Decision ldec;
|
|
|
if (auto st = gate(context, name, smartbotic::database::Access::Read, ldec);
|
|
if (auto st = gate(context, name, smartbotic::database::Access::Read, ldec);
|
|
|
!st.ok()) {
|
|
!st.ok()) {
|
|
@@ -1966,6 +1997,49 @@ grpc::Status DatabaseGrpcImpl::GetFileInfo(
|
|
|
return grpc::Status::OK;
|
|
return grpc::Status::OK;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::SetFileTtl(
|
|
|
|
|
+ grpc::ServerContext* context,
|
|
|
|
|
+ const pb::SetFileTtlRequest* request,
|
|
|
|
|
+ pb::SetFileTtlResponse* response
|
|
|
|
|
+) {
|
|
|
|
|
+ if (service_.isReadOnly()) {
|
|
|
|
|
+ return readOnlyStatus("SetFileTtl", service_);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Changing retention is a WRITE, gated on the record's own file type - which
|
|
|
|
|
+ // is only known after the lookup. A denial is reported as "not updated"
|
|
|
|
|
+ // rather than distinguished from a missing file.
|
|
|
|
|
+ auto info = files_.getFileInfoIn(request->project(), request->id());
|
|
|
|
|
+ if (!info) {
|
|
|
|
|
+ response->set_updated(false);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ {
|
|
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
|
|
+ if (auto st = gateFile(context, info->project, info->fileType,
|
|
|
|
|
+ smartbotic::database::Access::Write, fdec);
|
|
|
|
|
+ !st.ok()) {
|
|
|
|
|
+ response->set_updated(false);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Relative in, absolute stored - so a later read or a restart cannot extend
|
|
|
|
|
+ // the file's life. 0 clears the expiry.
|
|
|
|
|
+ uint64_t expiresAt = 0;
|
|
|
|
|
+ if (request->ttl_seconds() > 0) {
|
|
|
|
|
+ const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
|
|
|
+ std::chrono::system_clock::now().time_since_epoch()).count();
|
|
|
|
|
+ expiresAt = static_cast<uint64_t>(now) +
|
|
|
|
|
+ static_cast<uint64_t>(request->ttl_seconds()) * 1000ULL;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const bool ok = files_.setExpiryIn(request->project(), request->id(), expiresAt);
|
|
|
|
|
+ response->set_updated(ok);
|
|
|
|
|
+ response->set_expires_at(ok ? expiresAt : 0);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
|
grpc::ServerContext* context,
|
|
grpc::ServerContext* context,
|
|
|
const pb::ListFilesRequest* request,
|
|
const pb::ListFilesRequest* request,
|
|
@@ -1979,8 +2053,6 @@ grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
|
request->checksum(), request->name()
|
|
request->checksum(), request->name()
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- response->set_has_more(result.hasMore);
|
|
|
|
|
-
|
|
|
|
|
for (const auto& info : result.files) {
|
|
for (const auto& info : result.files) {
|
|
|
// v2.8.0 — filter per entry rather than refusing the whole listing, so a
|
|
// 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
|
|
// principal granted one file type still gets a usable result. total_count
|
|
@@ -2007,10 +2079,42 @@ grpc::Status DatabaseGrpcImpl::ListFiles(
|
|
|
file->set_expires_at(info.expiresAt);
|
|
file->set_expires_at(info.expiresAt);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 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()));
|
|
|
|
|
|
|
+ // v2.8.0 — total_count must reflect what the caller may actually SEE, but it
|
|
|
|
|
+ // must still be a TOTAL, not the size of this page.
|
|
|
|
|
+ //
|
|
|
|
|
+ // The first cut set it to files_size(), which is the page: with limit=5 on a
|
|
|
|
|
+ // 865-file project it reported 5, so a client could not page at all. Fixed
|
|
|
|
|
+ // by distinguishing the two cases:
|
|
|
|
|
+ //
|
|
|
|
|
+ // * nothing secured (the default, and every pre-2.7.0 deployment): no
|
|
|
|
|
+ // entry was filtered, so the store's own total is exactly right and
|
|
|
|
|
+ // costs nothing.
|
|
|
|
|
+ // * a project IS secured: recount over the whole matching set, because a
|
|
|
|
|
+ // filtered total cannot be derived from one page. Reporting the
|
|
|
|
|
+ // unfiltered total instead would leak how many files exist in file types
|
|
|
|
|
+ // this principal has no grant for.
|
|
|
|
|
+ if (!policy_manager_.anyProjectSecured()) {
|
|
|
|
|
+ response->set_total_count(result.totalCount);
|
|
|
|
|
+ response->set_has_more(
|
|
|
|
|
+ static_cast<uint64_t>(request->offset()) + response->files_size() <
|
|
|
|
|
+ result.totalCount);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const auto all = files_.listFiles(request->project(), request->file_type(),
|
|
|
|
|
+ request->related_id(),
|
|
|
|
|
+ /*limit=*/UINT32_MAX, /*offset=*/0,
|
|
|
|
|
+ request->checksum(), request->name());
|
|
|
|
|
+ uint64_t visible = 0;
|
|
|
|
|
+ for (const auto& info : all.files) {
|
|
|
|
|
+ smartbotic::database::Decision fdec;
|
|
|
|
|
+ if (gateFile(context, info.project, info.fileType,
|
|
|
|
|
+ smartbotic::database::Access::Read, fdec).ok()) {
|
|
|
|
|
+ ++visible;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ response->set_total_count(visible);
|
|
|
|
|
+ response->set_has_more(
|
|
|
|
|
+ static_cast<uint64_t>(request->offset()) + response->files_size() < visible);
|
|
|
|
|
+ }
|
|
|
return grpc::Status::OK;
|
|
return grpc::Status::OK;
|
|
|
}
|
|
}
|
|
|
|
|
|