anbaric-cloud-hosting 1.2.2 → 1.4.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 +5 -5
- package/src/app-management/DockerBuildLayer.ts +1 -0
- package/src/app-management/FargateBuildLayer.ts +1 -0
- package/src/auditing/AuditRecordStore.ts +19 -0
- package/src/auditing/InMemoryAuditRecordStore.ts +29 -0
- package/src/data-store/PostgresAuditRecordStore.ts +66 -0
- package/src/data-store/Schema.ts +26 -0
- package/src/hosting/HostingServer.ts +5 -3
- package/src/hosting/Router.ts +33 -2
- package/src/hosting/pages/platform-ui.html +2 -2
- package/src/index.ts +3 -0
- package/src/main.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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.
|
|
21
|
-
"anbaric-tsapi": "^1.
|
|
20
|
+
"anbaric-data-store": "^1.4.0",
|
|
21
|
+
"anbaric-tsapi": "^1.4.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.
|
|
28
|
-
"anbaric-state-machine": "^1.
|
|
27
|
+
"anbaric-cloud": "^1.4.0",
|
|
28
|
+
"anbaric-state-machine": "^1.4.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]);
|
|
@@ -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,19 @@
|
|
|
1
|
+
import {AuditChange, AuditRecord} from "anbaric-tsapi";
|
|
2
|
+
|
|
3
|
+
type AuditFilter = {
|
|
4
|
+
jobId? : string,
|
|
5
|
+
actorId? : string,
|
|
6
|
+
change? : AuditChange,
|
|
7
|
+
search? : string,
|
|
8
|
+
pageSize? : number,
|
|
9
|
+
page? : number,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
interface AuditRecordStore {
|
|
13
|
+
|
|
14
|
+
save(record : AuditRecord) : Promise<void>;
|
|
15
|
+
list(filter : AuditFilter) : Promise<Array<AuditRecord>>;
|
|
16
|
+
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type { AuditFilter, AuditRecordStore }
|
|
@@ -0,0 +1,29 @@
|
|
|
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.change || record.change === filter.change)
|
|
21
|
+
.filter(record => !filter.search ||
|
|
22
|
+
record.description.toLowerCase().includes(filter.search.toLowerCase()))
|
|
23
|
+
.reverse()
|
|
24
|
+
.slice(page * pageSize, (page + 1) * pageSize);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { InMemoryAuditRecordStore }
|
|
@@ -0,0 +1,66 @@
|
|
|
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, change, description, details)
|
|
12
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
13
|
+
[record.jobId, record.actorId, record.actorType, record.change,
|
|
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.change) {
|
|
31
|
+
parameters.push(filter.change);
|
|
32
|
+
conditions.push(`change = $${parameters.length}::anbaric_system.audit_change`);
|
|
33
|
+
}
|
|
34
|
+
if (filter.search) {
|
|
35
|
+
parameters.push(`%${filter.search}%`);
|
|
36
|
+
conditions.push(`description ILIKE $${parameters.length}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
40
|
+
parameters.push(filter.pageSize ?? 100);
|
|
41
|
+
const limit = `LIMIT $${parameters.length}`;
|
|
42
|
+
parameters.push((filter.page ?? 0) * (filter.pageSize ?? 100));
|
|
43
|
+
const offset = `OFFSET $${parameters.length}`;
|
|
44
|
+
|
|
45
|
+
const result = await this.pool.query(
|
|
46
|
+
`SELECT id, job_id, actor_id, actor_type, change, description, details, at
|
|
47
|
+
FROM anbaric_system.audit_records ${where}
|
|
48
|
+
ORDER BY at DESC, id DESC ${limit} ${offset}`,
|
|
49
|
+
parameters,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return result.rows.map(row => ({
|
|
53
|
+
id: String(row.id),
|
|
54
|
+
jobId: row.job_id,
|
|
55
|
+
actorId: row.actor_id,
|
|
56
|
+
actorType: row.actor_type,
|
|
57
|
+
change: row.change,
|
|
58
|
+
description: row.description,
|
|
59
|
+
details: row.details,
|
|
60
|
+
at: row.at.toISOString(),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { PostgresAuditRecordStore }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -52,6 +52,32 @@ 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
|
+
DO $$ BEGIN
|
|
57
|
+
CREATE TYPE anbaric_system.audit_change AS ENUM ('CREATE', 'UPDATE_PROPERTIES', 'CHANGE_STATE', 'DELETE');
|
|
58
|
+
EXCEPTION WHEN duplicate_object THEN null;
|
|
59
|
+
END $$
|
|
60
|
+
`);
|
|
61
|
+
await pool.query(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS anbaric_system.audit_records (
|
|
63
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
64
|
+
job_id TEXT NOT NULL,
|
|
65
|
+
actor_id TEXT NOT NULL,
|
|
66
|
+
actor_type TEXT NOT NULL,
|
|
67
|
+
change anbaric_system.audit_change NOT NULL,
|
|
68
|
+
description TEXT NOT NULL,
|
|
69
|
+
details JSONB,
|
|
70
|
+
at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
71
|
+
)
|
|
72
|
+
`);
|
|
73
|
+
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS change anbaric_system.audit_change");
|
|
74
|
+
await pool.query("UPDATE anbaric_system.audit_records SET change = 'UPDATE_PROPERTIES' WHERE change IS NULL");
|
|
75
|
+
await pool.query("UPDATE anbaric_system.audit_records SET actor_id = 'system' WHERE actor_id IS NULL");
|
|
76
|
+
await pool.query("UPDATE anbaric_system.audit_records SET actor_type = 'CODE' WHERE actor_type IS NULL");
|
|
77
|
+
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN change SET NOT NULL");
|
|
78
|
+
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN actor_id SET NOT NULL");
|
|
79
|
+
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN actor_type SET NOT NULL");
|
|
80
|
+
await pool.query("CREATE INDEX IF NOT EXISTS audit_records_job_id ON anbaric_system.audit_records (job_id)");
|
|
55
81
|
};
|
|
56
82
|
|
|
57
83
|
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
|
-
|
|
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
|
}
|
package/src/hosting/Router.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {readFile} from "node:fs/promises";
|
|
2
2
|
import {IncomingMessage, ServerResponse} from "node:http";
|
|
3
|
-
import {JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
|
|
3
|
+
import {AuditChange, JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
|
|
4
4
|
import {BuildLayer} from "../app-management/BuildLayer";
|
|
5
|
+
import {AuditRecordStore} from "../auditing/AuditRecordStore";
|
|
6
|
+
|
|
7
|
+
const AUDIT_CHANGES = new Set(["CREATE", "UPDATE_PROPERTIES", "CHANGE_STATE", "DELETE"]);
|
|
5
8
|
import {CliAuthorizer} from "../auth/CliAuthorizer";
|
|
6
9
|
import {Tenant} from "../auth/Tenant";
|
|
7
10
|
import {User} from "../auth/User";
|
|
@@ -29,7 +32,8 @@ class Router {
|
|
|
29
32
|
private documentStoreFor? : (collection : string) => JsonStore,
|
|
30
33
|
private secretStore? : SecretStore,
|
|
31
34
|
private cliAuthorizer? : CliAuthorizer,
|
|
32
|
-
private tenant? : string
|
|
35
|
+
private tenant? : string,
|
|
36
|
+
private auditRecords? : AuditRecordStore) {}
|
|
33
37
|
|
|
34
38
|
async route(request : IncomingMessage, response : ServerResponse, user? : User, sessionTenant? : Tenant) : Promise<void> {
|
|
35
39
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
@@ -52,6 +56,33 @@ class Router {
|
|
|
52
56
|
if (!user) return this.reply(response, 404, { error: "Not found" });
|
|
53
57
|
return this.reply(response, 200, { id: user.id, roles: user.roles.map(role => role.id) });
|
|
54
58
|
}
|
|
59
|
+
if (resource === "audits" && this.auditRecords && !id) {
|
|
60
|
+
if (method === "POST") {
|
|
61
|
+
const record = await readBody(request);
|
|
62
|
+
if (typeof record?.jobId !== "string" || typeof record?.description !== "string"
|
|
63
|
+
|| typeof record?.actorId !== "string" || typeof record?.actorType !== "string"
|
|
64
|
+
|| !AUDIT_CHANGES.has(record?.change)) {
|
|
65
|
+
return this.reply(response, 400, { error: "Expected a body of { jobId, actorId, actorType, change, description, ... }" });
|
|
66
|
+
}
|
|
67
|
+
await this.auditRecords.save(record);
|
|
68
|
+
return this.reply(response, 204);
|
|
69
|
+
}
|
|
70
|
+
if (method === "GET") {
|
|
71
|
+
const change = url.searchParams.get("change");
|
|
72
|
+
const records = await this.auditRecords.list({
|
|
73
|
+
jobId: url.searchParams.get("jobId") ?? undefined,
|
|
74
|
+
actorId: url.searchParams.get("actorId") ?? undefined,
|
|
75
|
+
change: change && AUDIT_CHANGES.has(change) ? change as AuditChange : undefined,
|
|
76
|
+
search: url.searchParams.get("search") ?? undefined,
|
|
77
|
+
pageSize: url.searchParams.has("pageSize") ? Number(url.searchParams.get("pageSize")) : undefined,
|
|
78
|
+
page: url.searchParams.has("page") ? Number(url.searchParams.get("page")) : undefined,
|
|
79
|
+
});
|
|
80
|
+
return this.reply(response, 200, records);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (resource === "audit" && method === "GET" && !id) {
|
|
84
|
+
return this.servePage(response);
|
|
85
|
+
}
|
|
55
86
|
if (resource === "jobs") return this.handleJobs(method, id, subresource, url, request, response);
|
|
56
87
|
if (resource === "queue" && method === "POST" && !subresource) return this.handleQueue(id, request, response);
|
|
57
88
|
if (resource === "consumers" && method === "POST" && !id) {
|