anbaric-cloud-hosting 1.2.1 → 1.3.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.2.1",
3
+ "version": "1.3.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",
@@ -17,15 +17,15 @@
17
17
  "@aws-sdk/client-s3": "^3.1110.0",
18
18
  "@aws-sdk/client-secrets-manager": "^3.700.0",
19
19
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
20
- "anbaric-data-store": "^1.2.1",
21
- "anbaric-tsapi": "^1.2.1",
20
+ "anbaric-data-store": "^1.3.0",
21
+ "anbaric-tsapi": "^1.3.0",
22
22
  "pg": "^8.16.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^26.2.0",
26
26
  "@types/pg": "^8.15.0",
27
- "anbaric-cloud": "^1.2.1",
28
- "anbaric-state-machine": "^1.2.1",
27
+ "anbaric-cloud": "^1.3.0",
28
+ "anbaric-state-machine": "^1.3.0",
29
29
  "tsx": "^4.20.0",
30
30
  "typescript": "^7.0.2"
31
31
  },
@@ -59,6 +59,7 @@ class DockerBuildLayer extends BaseBuildLayer {
59
59
  "--env", "ANBARIC_QUEUE_TYPE=cloud",
60
60
  "--env", "ANBARIC_JSON_STORE_TYPE=cloud",
61
61
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
62
+ "--env", "ANBARIC_AUDITOR_TYPE=cloud",
62
63
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
63
64
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
64
65
  image]);
@@ -40,7 +40,7 @@ const defaultClients = (region : string) : AwsClients => ({
40
40
  });
41
41
 
42
42
  const BUILD_TIMEOUT_MS = 900_000;
43
- const FARGATE_LIVENESS_TIMEOUT_MS = 180_000;
43
+ const FARGATE_LIVENESS_TIMEOUT_MS = 420_000;
44
44
 
45
45
  class FargateBuildLayer extends BaseBuildLayer {
46
46
 
@@ -148,6 +148,7 @@ class FargateBuildLayer extends BaseBuildLayer {
148
148
  { name: "ANBARIC_QUEUE_TYPE", value: "cloud" },
149
149
  { name: "ANBARIC_JSON_STORE_TYPE", value: "cloud" },
150
150
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
151
+ { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
151
152
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
152
153
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
153
154
  ],
@@ -0,0 +1,18 @@
1
+ import {AuditRecord} from "anbaric-tsapi";
2
+
3
+ type AuditFilter = {
4
+ jobId? : string,
5
+ actorId? : string,
6
+ search? : string,
7
+ pageSize? : number,
8
+ page? : number,
9
+ };
10
+
11
+ interface AuditRecordStore {
12
+
13
+ save(record : AuditRecord) : Promise<void>;
14
+ list(filter : AuditFilter) : Promise<Array<AuditRecord>>;
15
+
16
+ }
17
+
18
+ export type { AuditFilter, AuditRecordStore }
@@ -0,0 +1,28 @@
1
+ import {AuditRecord} from "anbaric-tsapi";
2
+ import {randomUUID} from "node:crypto";
3
+ import {AuditFilter, AuditRecordStore} from "./AuditRecordStore";
4
+
5
+ class InMemoryAuditRecordStore implements AuditRecordStore {
6
+
7
+ private records = new Array<AuditRecord>();
8
+
9
+ async save(record : AuditRecord) : Promise<void> {
10
+ this.records.push({ ...record, id: randomUUID(), at: record.at ?? new Date().toISOString() });
11
+ }
12
+
13
+ async list(filter : AuditFilter) : Promise<Array<AuditRecord>> {
14
+ const pageSize = filter.pageSize ?? 100;
15
+ const page = filter.page ?? 0;
16
+
17
+ return this.records
18
+ .filter(record => !filter.jobId || record.jobId === filter.jobId)
19
+ .filter(record => !filter.actorId || record.actorId === filter.actorId)
20
+ .filter(record => !filter.search ||
21
+ record.description.toLowerCase().includes(filter.search.toLowerCase()))
22
+ .reverse()
23
+ .slice(page * pageSize, (page + 1) * pageSize);
24
+ }
25
+
26
+ }
27
+
28
+ export { InMemoryAuditRecordStore }
@@ -0,0 +1,61 @@
1
+ import {AuditRecord} from "anbaric-tsapi";
2
+ import {Pool} from "pg";
3
+ import {AuditFilter, AuditRecordStore} from "../auditing/AuditRecordStore";
4
+
5
+ class PostgresAuditRecordStore implements AuditRecordStore {
6
+
7
+ constructor(private pool : Pool) {}
8
+
9
+ async save(record : AuditRecord) : Promise<void> {
10
+ await this.pool.query(
11
+ `INSERT INTO anbaric_system.audit_records (job_id, actor_id, actor_type, description, details)
12
+ VALUES ($1, $2, $3, $4, $5)`,
13
+ [record.jobId, record.actorId ?? null, record.actorType ?? null,
14
+ record.description, JSON.stringify(record.details ?? null)],
15
+ );
16
+ }
17
+
18
+ async list(filter : AuditFilter) : Promise<Array<AuditRecord>> {
19
+ const conditions : Array<string> = [];
20
+ const parameters : Array<any> = [];
21
+
22
+ if (filter.jobId) {
23
+ parameters.push(filter.jobId);
24
+ conditions.push(`job_id = $${parameters.length}`);
25
+ }
26
+ if (filter.actorId) {
27
+ parameters.push(filter.actorId);
28
+ conditions.push(`actor_id = $${parameters.length}`);
29
+ }
30
+ if (filter.search) {
31
+ parameters.push(`%${filter.search}%`);
32
+ conditions.push(`description ILIKE $${parameters.length}`);
33
+ }
34
+
35
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
36
+ parameters.push(filter.pageSize ?? 100);
37
+ const limit = `LIMIT $${parameters.length}`;
38
+ parameters.push((filter.page ?? 0) * (filter.pageSize ?? 100));
39
+ const offset = `OFFSET $${parameters.length}`;
40
+
41
+ const result = await this.pool.query(
42
+ `SELECT id, job_id, actor_id, actor_type, description, details, at
43
+ FROM anbaric_system.audit_records ${where}
44
+ ORDER BY at DESC, id DESC ${limit} ${offset}`,
45
+ parameters,
46
+ );
47
+
48
+ return result.rows.map(row => ({
49
+ id: String(row.id),
50
+ jobId: row.job_id,
51
+ actorId: row.actor_id ?? undefined,
52
+ actorType: row.actor_type ?? undefined,
53
+ description: row.description,
54
+ details: row.details,
55
+ at: row.at.toISOString(),
56
+ }));
57
+ }
58
+
59
+ }
60
+
61
+ export { PostgresAuditRecordStore }
@@ -52,6 +52,18 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
52
52
  )
53
53
  `);
54
54
  await pool.query("ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT");
55
+ await pool.query(`
56
+ CREATE TABLE IF NOT EXISTS anbaric_system.audit_records (
57
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
58
+ job_id TEXT NOT NULL,
59
+ actor_id TEXT,
60
+ actor_type TEXT,
61
+ description TEXT NOT NULL,
62
+ details JSONB,
63
+ at TIMESTAMPTZ NOT NULL DEFAULT now()
64
+ )
65
+ `);
66
+ await pool.query("CREATE INDEX IF NOT EXISTS audit_records_job_id ON anbaric_system.audit_records (job_id)");
55
67
  };
56
68
 
57
69
  export { ensureSchema }
@@ -2,6 +2,7 @@ import {createServer, IncomingMessage, Server, ServerResponse} from "node:http";
2
2
  import {AddressInfo} from "node:net";
3
3
  import {JobPersistence, JsonStore, SecretStore} from "anbaric-tsapi";
4
4
  import {BuildLayer} from "../app-management/BuildLayer";
5
+ import {AuditRecordStore} from "../auditing/AuditRecordStore";
5
6
  import {Authenticator, SESSION_COOKIE} from "../auth/Authenticator";
6
7
  import {CliAuthorizer} from "../auth/CliAuthorizer";
7
8
  import {Tenant} from "../auth/Tenant";
@@ -11,7 +12,7 @@ import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
11
12
  import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
12
13
  import {Router} from "./Router";
13
14
 
14
- const INTERNAL_RESOURCES = new Set(["jobs", "queue", "consumers", "state-machines", "documents", "secrets"]);
15
+ const INTERNAL_RESOURCES = new Set(["jobs", "queue", "consumers", "state-machines", "documents", "secrets", "audits"]);
15
16
 
16
17
  class HostingServer {
17
18
 
@@ -27,8 +28,9 @@ class HostingServer {
27
28
  private authenticator? : Authenticator,
28
29
  cliAuthorizer? : CliAuthorizer,
29
30
  private tokenAuthenticator? : TokenAuthenticator,
30
- private tenant? : string) {
31
- this.router = new Router(persistence, queue, registry, buildLayer, documentStoreFor, secretStore, cliAuthorizer, tenant);
31
+ private tenant? : string,
32
+ auditRecords? : AuditRecordStore) {
33
+ this.router = new Router(persistence, queue, registry, buildLayer, documentStoreFor, secretStore, cliAuthorizer, tenant, auditRecords);
32
34
  this.server = this.serverFor((request, response) => this.handle(request, response));
33
35
  this.internalServer = this.serverFor((request, response) => this.handleInternal(request, response));
34
36
  }
@@ -2,6 +2,7 @@ import {readFile} from "node:fs/promises";
2
2
  import {IncomingMessage, ServerResponse} from "node:http";
3
3
  import {JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
4
4
  import {BuildLayer} from "../app-management/BuildLayer";
5
+ import {AuditRecordStore} from "../auditing/AuditRecordStore";
5
6
  import {CliAuthorizer} from "../auth/CliAuthorizer";
6
7
  import {Tenant} from "../auth/Tenant";
7
8
  import {User} from "../auth/User";
@@ -29,7 +30,8 @@ class Router {
29
30
  private documentStoreFor? : (collection : string) => JsonStore,
30
31
  private secretStore? : SecretStore,
31
32
  private cliAuthorizer? : CliAuthorizer,
32
- private tenant? : string) {}
33
+ private tenant? : string,
34
+ private auditRecords? : AuditRecordStore) {}
33
35
 
34
36
  async route(request : IncomingMessage, response : ServerResponse, user? : User, sessionTenant? : Tenant) : Promise<void> {
35
37
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -52,6 +54,29 @@ class Router {
52
54
  if (!user) return this.reply(response, 404, { error: "Not found" });
53
55
  return this.reply(response, 200, { id: user.id, roles: user.roles.map(role => role.id) });
54
56
  }
57
+ if (resource === "audits" && this.auditRecords && !id) {
58
+ if (method === "POST") {
59
+ const record = await readBody(request);
60
+ if (typeof record?.jobId !== "string" || typeof record?.description !== "string") {
61
+ return this.reply(response, 400, { error: "Expected a body of { jobId, description, ... }" });
62
+ }
63
+ await this.auditRecords.save(record);
64
+ return this.reply(response, 204);
65
+ }
66
+ if (method === "GET") {
67
+ const records = await this.auditRecords.list({
68
+ jobId: url.searchParams.get("jobId") ?? undefined,
69
+ actorId: url.searchParams.get("actorId") ?? undefined,
70
+ search: url.searchParams.get("search") ?? undefined,
71
+ pageSize: url.searchParams.has("pageSize") ? Number(url.searchParams.get("pageSize")) : undefined,
72
+ page: url.searchParams.has("page") ? Number(url.searchParams.get("page")) : undefined,
73
+ });
74
+ return this.reply(response, 200, records);
75
+ }
76
+ }
77
+ if (resource === "audit" && method === "GET" && !id) {
78
+ return this.servePage(response);
79
+ }
55
80
  if (resource === "jobs") return this.handleJobs(method, id, subresource, url, request, response);
56
81
  if (resource === "queue" && method === "POST" && !subresource) return this.handleQueue(id, request, response);
57
82
  if (resource === "consumers" && method === "POST" && !id) {