anbaric-cloud-hosting 1.61.0 → 1.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.61.0",
3
+ "version": "1.63.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.61.0",
22
- "anbaric-plugins": "^1.61.0",
23
- "anbaric-tsapi": "^1.61.0",
24
- "anbaric-web": "^1.61.0",
21
+ "anbaric-data-store": "^1.63.0",
22
+ "anbaric-plugins": "^1.63.0",
23
+ "anbaric-tsapi": "^1.63.0",
24
+ "anbaric-web": "^1.63.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.61.0",
32
- "anbaric-state-machine": "^1.61.0",
31
+ "anbaric-impl-cloud": "^1.63.0",
32
+ "anbaric-state-machine": "^1.63.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -16,6 +16,12 @@ interface AuditRecordStore {
16
16
  save(record : AuditRecord) : Promise<void>;
17
17
  list(filter : AuditFilter) : Promise<Array<AuditRecord>>;
18
18
 
19
+ /* Removes records older than the moment given, at most `limit` of them,
20
+ oldest first, and says how many went - so a backlog is cleared a batch
21
+ at a time rather than in one statement that holds a lock and leaves
22
+ every dead row behind at once. */
23
+ deleteBefore(before : Date, limit : number) : Promise<number>;
24
+
19
25
  }
20
26
 
21
27
  export type { AuditFilter, AuditRecordStore }
@@ -0,0 +1,67 @@
1
+ import {AuditRecordStore} from "./AuditRecordStore";
2
+
3
+ const SWEEP_INTERVAL_MS = 24 * 60 * 60_000;
4
+ const BATCH_SIZE = 5_000;
5
+ const BATCHES_PER_SWEEP = 200;
6
+
7
+ /* How long audit records are kept unless the platform is told otherwise. A
8
+ year: long enough that nobody is surprised by what has gone, short enough
9
+ that the table does not grow for ever. Zero means keep everything. */
10
+ const DEFAULT_RETENTION_DAYS = 365;
11
+
12
+ const DAY_MS = 24 * 60 * 60_000;
13
+
14
+ /* Removes audit records older than the retention, once a day and once at
15
+ start - a platform that restarts on every release would otherwise never
16
+ reach its first daily tick. Deletion is done a batch at a time rather than
17
+ in one statement, so clearing a backlog neither holds a lock for long nor
18
+ leaves more dead rows at once than autovacuum can keep up with, and each
19
+ sweep stops after a bounded number of batches so a very large backlog is
20
+ worked through over several days rather than in one sitting. */
21
+ class AuditSweep {
22
+
23
+ private ticker? : NodeJS.Timeout;
24
+ private sweeping = false;
25
+
26
+ constructor(private records : AuditRecordStore,
27
+ private retentionDays : number = DEFAULT_RETENTION_DAYS,
28
+ private intervalMs : number = SWEEP_INTERVAL_MS,
29
+ private batchSize : number = BATCH_SIZE) {}
30
+
31
+ start() : void {
32
+ if (this.ticker || this.retentionDays <= 0) return;
33
+ void this.sweep();
34
+ this.ticker = setInterval(() => void this.sweep(), this.intervalMs);
35
+ this.ticker.unref();
36
+ }
37
+
38
+ async sweep() : Promise<number> {
39
+ if (this.sweeping || this.retentionDays <= 0) return 0;
40
+ this.sweeping = true;
41
+
42
+ const before = new Date(Date.now() - this.retentionDays * DAY_MS);
43
+ let removed = 0;
44
+ try {
45
+ for (let batch = 0; batch < BATCHES_PER_SWEEP; batch++) {
46
+ const went = await this.records.deleteBefore(before, this.batchSize);
47
+ removed += went;
48
+ if (went < this.batchSize) break;
49
+ }
50
+ if (removed > 0) console.log(`[audit] removed ${removed} record(s) older than ${this.retentionDays} days`);
51
+ return removed;
52
+ } catch (error) {
53
+ console.error(`[audit] the retention sweep failed: ${error instanceof Error ? error.message : error}`);
54
+ return removed;
55
+ } finally {
56
+ this.sweeping = false;
57
+ }
58
+ }
59
+
60
+ async cleanUp() : Promise<void> {
61
+ if (this.ticker) clearInterval(this.ticker);
62
+ this.ticker = undefined;
63
+ }
64
+
65
+ }
66
+
67
+ export { AuditSweep, DEFAULT_RETENTION_DAYS };
@@ -26,6 +26,16 @@ class InMemoryAuditRecordStore implements AuditRecordStore {
26
26
  .slice(page * pageSize, (page + 1) * pageSize);
27
27
  }
28
28
 
29
+ async deleteBefore(before : Date, limit : number) : Promise<number> {
30
+ const stale = this.records
31
+ .filter(record => new Date(record.at ?? 0) < before)
32
+ .sort((left, right) => String(left.at).localeCompare(String(right.at)))
33
+ .slice(0, limit);
34
+
35
+ this.records = this.records.filter(record => ! stale.includes(record));
36
+ return stale.length;
37
+ }
38
+
29
39
  }
30
40
 
31
41
  export { InMemoryAuditRecordStore }
@@ -46,6 +46,12 @@ class HttpMembershipService implements MembershipService {
46
46
  return response.status === 204;
47
47
  }
48
48
 
49
+ async remove(userId : string, asking : Inviter) : Promise<boolean> {
50
+ const response = await this.call("DELETE", `/members/${encodeURIComponent(userId)}`, undefined, [404], asking);
51
+ this.roles.delete(userId);
52
+ return response.status === 204;
53
+ }
54
+
49
55
  private async call(method : string, path : string, body? : unknown,
50
56
  tolerated : Array<number> = [], asking? : Inviter) : Promise<Response> {
51
57
  const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
@@ -17,6 +17,10 @@ class InMemoryMembershipService implements MembershipService {
17
17
  return this.members.get(userId);
18
18
  }
19
19
 
20
+ async remove(userId : string, _asking : Inviter) : Promise<boolean> {
21
+ return this.members.delete(userId);
22
+ }
23
+
20
24
  async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
21
25
  const token = randomUUID();
22
26
  const now = new Date();
@@ -39,6 +39,9 @@ interface MembershipService {
39
39
 
40
40
  revoke(token : string, asking : Inviter) : Promise<boolean>;
41
41
 
42
+ // Ends someone's membership. False when they were not a member.
43
+ remove(userId : string, asking : Inviter) : Promise<boolean>;
44
+
42
45
  }
43
46
 
44
47
  export type { MembershipService, PendingInvitation, Inviter }
@@ -256,6 +256,12 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
256
256
  ON entitlement_invites (email, COALESCE(app_id, ''), entitlement_id)`,
257
257
  ),
258
258
 
259
+ // Audit records are weeded by age, which without this is a scan of the
260
+ // whole table every day.
261
+ statements("006-audit-records-by-age",
262
+ "CREATE INDEX IF NOT EXISTS audit_records_at ON anbaric_system.audit_records (at)",
263
+ ),
264
+
259
265
  ];
260
266
 
261
267
  export { PLATFORM_MIGRATIONS };
@@ -92,6 +92,21 @@ class PostgresAuditRecordStore implements AuditRecordStore {
92
92
  }));
93
93
  }
94
94
 
95
+ /* Oldest first and bounded, so the sweep that calls this can work through
96
+ a large backlog a batch at a time. The subquery is what the index on
97
+ `at` serves; the delete itself goes by primary key. */
98
+ async deleteBefore(before : Date, limit : number) : Promise<number> {
99
+ const result = await this.pool.query(
100
+ `DELETE FROM anbaric_system.audit_records
101
+ WHERE id IN (
102
+ SELECT id FROM anbaric_system.audit_records
103
+ WHERE at < $1 ORDER BY at, id LIMIT $2
104
+ )`,
105
+ [before, limit],
106
+ );
107
+ return result.rowCount ?? 0;
108
+ }
109
+
95
110
  }
96
111
 
97
112
  export { PostgresAuditRecordStore }
@@ -10,6 +10,7 @@ import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
10
  import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
11
11
  import {AppsHandler} from "./handlers/AppsHandler";
12
12
  import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
+ import {MembersHandler} from "./handlers/auth/MembersHandler";
13
14
  import {Subdomains} from "../subdomains/Subdomains";
14
15
  import {AuditsHandler} from "./handlers/AuditsHandler";
15
16
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -111,6 +112,7 @@ class HostingServer {
111
112
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
112
113
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
113
114
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
115
+ if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
114
116
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
115
117
  if (prompts) publicRouter.registerApi("prompts", prompts);
116
118
  if (cliAuthorizer) {
@@ -0,0 +1,43 @@
1
+ import {MembershipService} from "../../../auth/MembershipService";
2
+ import {canInvite} from "../../../auth/TenantRole";
3
+ import {EntitlementStore} from "../../../data-store/EntitlementStore";
4
+ import {Request} from "../../Request";
5
+ import {RequestHandler} from "../../RequestHandler";
6
+
7
+ /* The console's side of removing someone from this tenant. Membership is the
8
+ control plane's to end - it is what admits a session at all - and it is
9
+ asked to; what is this platform's own is the entitlements that person held
10
+ here, which go with them so nothing is left granted to somebody who can no
11
+ longer sign in. Their directory entry stays: it is a record of who was here. */
12
+ class MembersHandler implements RequestHandler {
13
+
14
+ constructor(private memberships : MembershipService, private entitlements? : EntitlementStore) {}
15
+
16
+ async handle(request : Request) : Promise<void> {
17
+ if (request.method !== "DELETE" || ! request.id || request.subresource) return request.notFound();
18
+
19
+ if (! canInvite(request.user?.tenantRole)) {
20
+ return request.reply(403, { error: "Your role in this tenant cannot remove members" });
21
+ }
22
+ if (request.user?.id === request.id) {
23
+ return request.reply(400, { error: "You cannot remove yourself from a tenant" });
24
+ }
25
+
26
+ const asking = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
27
+ let removed : boolean;
28
+ try {
29
+ removed = await this.memberships.remove(request.id, asking);
30
+ } catch (error) {
31
+ return request.reply(403, { error: error instanceof Error ? error.message : "Could not remove that member" });
32
+ }
33
+ if (! removed) return request.notFound();
34
+
35
+ for (const grant of await this.entitlements?.listGrants(request.id) ?? []) {
36
+ await this.entitlements?.revoke(grant.id);
37
+ }
38
+ request.reply(204);
39
+ }
40
+
41
+ }
42
+
43
+ export { MembersHandler }
package/src/main.ts CHANGED
@@ -18,6 +18,7 @@ import {S3FileStorage} from "./data-store/S3FileStorage";
18
18
  import {PostgresJobRunSchedulePersistence} from "./data-store/PostgresJobRunSchedulePersistence";
19
19
  import {PostgresJobPersistence} from "./data-store/PostgresJobPersistence";
20
20
  import {StallSweep} from "./data-store/StallSweep";
21
+ import {AuditSweep, DEFAULT_RETENTION_DAYS} from "./auditing/AuditSweep";
21
22
  import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
22
23
  import {PostgresQueue} from "./queuing/PostgresQueue";
23
24
  import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
@@ -130,9 +131,10 @@ const fileStorageFor = (appId : string) : FileStorage =>
130
131
  : new LocalFileSystemStorage(join(process.env.ANBARIC_FILE_STORAGE_PATH ?? join(tmpdir(), "anbaric", "files"), appId));
131
132
 
132
133
  const jobs = new PostgresJobPersistence(pool);
134
+ const auditRecords = new PostgresAuditRecordStore(pool);
133
135
  const server = new HostingServer(jobs, queue, registry, buildLayer,
134
136
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
135
- tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
137
+ tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
136
138
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
137
139
  new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
138
140
  (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains);
@@ -169,4 +171,8 @@ if (buildLayer && subdomains) {
169
171
  // Jobs whose long-running step stopped saying it was alive are marked, not touched.
170
172
  new StallSweep(jobs).start();
171
173
 
174
+ /* Audit records older than the retention are removed, daily. The retention is
175
+ in days; zero keeps everything, and an unset value keeps a year. */
176
+ new AuditSweep(auditRecords, Number(process.env.ANBARIC_AUDIT_RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS)).start();
177
+
172
178
  console.log(`anbaric-cloud-hosting listening on port ${port}, internal entry point on ${internal}`);