anbaric-cloud-hosting 1.20.0 → 1.21.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 +7 -6
- package/src/auditing/AuditRecordStore.ts +1 -0
- package/src/auditing/InMemoryAuditRecordStore.ts +1 -0
- package/src/auth/Authenticator.ts +8 -0
- package/src/auth/SessionSigner.ts +9 -2
- package/src/data-store/PostgresAuditRecordStore.ts +10 -5
- package/src/data-store/PostgresJobPersistence.ts +8 -6
- package/src/data-store/PostgresJsonStore.ts +12 -9
- package/src/data-store/Schema.ts +27 -1
- package/src/hosting/HostingServer.ts +7 -3
- package/src/hosting/Request.ts +11 -0
- package/src/hosting/handlers/AppLinkFallbackHandler.ts +26 -0
- package/src/hosting/handlers/AuditsHandler.ts +1 -0
- package/src/hosting/handlers/ConsumersHandler.ts +2 -2
- package/src/hosting/handlers/DocumentsHandler.ts +4 -2
- package/src/hosting/handlers/QueueHandler.ts +6 -6
- package/src/hosting/handlers/SecretsHandler.ts +11 -9
- package/src/hosting/middleware/AuthenticationMiddleware.ts +13 -0
- package/src/main.ts +10 -4
- package/src/queuing/ConsumerRegistry.ts +13 -7
- package/src/queuing/Dispatcher.ts +3 -3
- package/src/queuing/PostgresQueue.ts +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.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,17 +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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.21.0",
|
|
22
|
+
"anbaric-plugins": "^1.21.0",
|
|
23
|
+
"anbaric-tsapi": "^1.21.0",
|
|
24
|
+
"anbaric-web": "^1.21.0",
|
|
24
25
|
"esbuild": "^0.28.2",
|
|
25
26
|
"pg": "^8.16.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@types/node": "^26.2.0",
|
|
29
30
|
"@types/pg": "^8.15.0",
|
|
30
|
-
"anbaric-impl-cloud": "^1.
|
|
31
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.21.0",
|
|
32
|
+
"anbaric-state-machine": "^1.21.0",
|
|
32
33
|
"tsx": "^4.20.0",
|
|
33
34
|
"typescript": "^7.0.2"
|
|
34
35
|
},
|
|
@@ -15,6 +15,7 @@ class InMemoryAuditRecordStore implements AuditRecordStore {
|
|
|
15
15
|
const page = filter.page ?? 0;
|
|
16
16
|
|
|
17
17
|
return this.records
|
|
18
|
+
.filter(record => !filter.appId || record.appId === filter.appId)
|
|
18
19
|
.filter(record => !filter.resourceType || record.resourceType === filter.resourceType)
|
|
19
20
|
.filter(record => !filter.resourceId || record.resourceId === filter.resourceId)
|
|
20
21
|
.filter(record => !filter.actorId || record.actorId === filter.actorId)
|
|
@@ -13,6 +13,14 @@ abstract class Authenticator {
|
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// True for authenticators that answer an unauthenticated request with a
|
|
17
|
+
// redirect to an interactive login (an identity provider). The platform
|
|
18
|
+
// uses this to avoid discarding the body of a state-changing request that
|
|
19
|
+
// hits an expired session - it fails such a request cleanly instead.
|
|
20
|
+
redirectsToLoginOnFailure() : boolean {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
export { Authenticator, SESSION_COOKIE }
|
|
@@ -7,6 +7,11 @@ import {User} from "./User";
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
9
9
|
|
|
10
|
+
const configuredTtlSeconds = () => {
|
|
11
|
+
const seconds = Number(process.env.ANBARIC_SESSION_TTL_SECONDS);
|
|
12
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_TTL_SECONDS;
|
|
13
|
+
};
|
|
14
|
+
|
|
10
15
|
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
11
16
|
|
|
12
17
|
const base64url = (input : Buffer | string) => Buffer.from(input).toString("base64url");
|
|
@@ -18,11 +23,13 @@ const base64url = (input : Buffer | string) => Buffer.from(input).toString("base
|
|
|
18
23
|
store. The signing secret lives in the environment
|
|
19
24
|
(ANBARIC_SESSION_SIGNING_SECRET, sourced from a secret manager); with none
|
|
20
25
|
set the signer is inert and the platform falls back to the authenticator.
|
|
21
|
-
Sessions slide: a valid one is re-issued with a fresh expiry on each request.
|
|
26
|
+
Sessions slide: a valid one is re-issued with a fresh expiry on each request.
|
|
27
|
+
The lifetime defaults to 30 days and can be tuned with
|
|
28
|
+
ANBARIC_SESSION_TTL_SECONDS. */
|
|
22
29
|
class SessionSigner {
|
|
23
30
|
|
|
24
31
|
constructor(private secret : string = process.env.ANBARIC_SESSION_SIGNING_SECRET ?? "",
|
|
25
|
-
private ttlSeconds : number =
|
|
32
|
+
private ttlSeconds : number = configuredTtlSeconds()) {}
|
|
26
33
|
|
|
27
34
|
get configured() : boolean {
|
|
28
35
|
return this.secret.length > 0;
|
|
@@ -29,10 +29,10 @@ class PostgresAuditRecordStore implements AuditRecordStore {
|
|
|
29
29
|
|
|
30
30
|
await this.pool.query(
|
|
31
31
|
`INSERT INTO anbaric_system.audit_records
|
|
32
|
-
(resource_type, resource_id, actor_id, actor_type, interaction, description, details)
|
|
33
|
-
VALUES ($1, $2, $3, $4, $5::text[], $
|
|
34
|
-
[record.resourceType, record.resourceId, record.actorId, record.actorType,
|
|
35
|
-
record.description, JSON.stringify(record.details ?? null)],
|
|
32
|
+
(app_id, resource_type, resource_id, actor_id, actor_type, interaction, description, details)
|
|
33
|
+
VALUES ($1, $2, $3, $4, $5, $6::text[], $7, $8)`,
|
|
34
|
+
[record.appId || null, record.resourceType, record.resourceId, record.actorId, record.actorType,
|
|
35
|
+
record.interaction, record.description, JSON.stringify(record.details ?? null)],
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -40,6 +40,10 @@ class PostgresAuditRecordStore implements AuditRecordStore {
|
|
|
40
40
|
const conditions : Array<string> = [];
|
|
41
41
|
const parameters : Array<any> = [];
|
|
42
42
|
|
|
43
|
+
if (filter.appId) {
|
|
44
|
+
parameters.push(filter.appId);
|
|
45
|
+
conditions.push(`app_id = $${parameters.length}`);
|
|
46
|
+
}
|
|
43
47
|
if (filter.resourceType) {
|
|
44
48
|
parameters.push(filter.resourceType);
|
|
45
49
|
conditions.push(`resource_type = $${parameters.length}`);
|
|
@@ -68,7 +72,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
|
|
|
68
72
|
const offset = `OFFSET $${parameters.length}`;
|
|
69
73
|
|
|
70
74
|
const result = await this.pool.query(
|
|
71
|
-
`SELECT id, resource_type, resource_id, actor_id, actor_type, interaction, description, details, at
|
|
75
|
+
`SELECT id, app_id, resource_type, resource_id, actor_id, actor_type, interaction, description, details, at
|
|
72
76
|
FROM anbaric_system.audit_records ${where}
|
|
73
77
|
ORDER BY at DESC, id DESC ${limit} ${offset}`,
|
|
74
78
|
parameters,
|
|
@@ -76,6 +80,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
|
|
|
76
80
|
|
|
77
81
|
return result.rows.map(row => ({
|
|
78
82
|
id: String(row.id),
|
|
83
|
+
appId: row.app_id ?? undefined,
|
|
79
84
|
resourceType: row.resource_type,
|
|
80
85
|
resourceId: row.resource_id,
|
|
81
86
|
actorId: row.actor_id,
|
|
@@ -6,6 +6,7 @@ type JobRow = {
|
|
|
6
6
|
state : string,
|
|
7
7
|
properties : Record<string, any>,
|
|
8
8
|
workflow_id? : string,
|
|
9
|
+
app_id? : string,
|
|
9
10
|
started_at : Date,
|
|
10
11
|
started_by : string,
|
|
11
12
|
last_updated : Date,
|
|
@@ -19,7 +20,7 @@ type JobRow = {
|
|
|
19
20
|
carry a waiting_for foreign key, the metadata lives once in awaits, and the
|
|
20
21
|
job's awaitMetadata is rejoined on read. */
|
|
21
22
|
const JOB_SELECT =
|
|
22
|
-
`SELECT j.id, j.state, j.properties, j.workflow_id, j.started_at, j.started_by, j.last_updated,
|
|
23
|
+
`SELECT j.id, j.state, j.properties, j.workflow_id, j.app_id, j.started_at, j.started_by, j.last_updated,
|
|
23
24
|
j.killed, j.status, j.waiting_for, a.metadata AS await_metadata
|
|
24
25
|
FROM jobs j LEFT JOIN awaits a ON a.id = j.waiting_for`;
|
|
25
26
|
|
|
@@ -46,12 +47,12 @@ class PostgresJobPersistence extends JobPersistence {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
await client.query(
|
|
49
|
-
`INSERT INTO jobs (id, state, properties, workflow_id, started_at, started_by, last_updated, killed, status, waiting_for)
|
|
50
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
50
|
+
`INSERT INTO jobs (id, state, properties, workflow_id, app_id, started_at, started_by, last_updated, killed, status, waiting_for)
|
|
51
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
51
52
|
ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, properties = EXCLUDED.properties,
|
|
52
|
-
workflow_id = EXCLUDED.workflow_id,
|
|
53
|
-
status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
|
|
54
|
-
[job.id, job.state, Object.fromEntries(job.properties), job.workflowId,
|
|
53
|
+
workflow_id = EXCLUDED.workflow_id, app_id = EXCLUDED.app_id, last_updated = EXCLUDED.last_updated,
|
|
54
|
+
killed = EXCLUDED.killed, status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
|
|
55
|
+
[job.id, job.state, Object.fromEntries(job.properties), job.workflowId, job.appId || null,
|
|
55
56
|
job.startedAt, job.startedBy, job.lastUpdated, job.killed, job.status, job.waitingFor ?? null],
|
|
56
57
|
);
|
|
57
58
|
|
|
@@ -112,6 +113,7 @@ class PostgresJobPersistence extends JobPersistence {
|
|
|
112
113
|
return deserializeJob({
|
|
113
114
|
...row,
|
|
114
115
|
workflowId: row.workflow_id ?? undefined,
|
|
116
|
+
appId: row.app_id ?? undefined,
|
|
115
117
|
startedAt: row.started_at.toISOString(),
|
|
116
118
|
startedBy: row.started_by,
|
|
117
119
|
lastUpdated: row.last_updated.toISOString(),
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import {Auditor, JsonStore, NoOpAuditor} from "anbaric-tsapi";
|
|
2
2
|
import {Pool} from "pg";
|
|
3
3
|
|
|
4
|
+
/* Documents are keyed by the composite (app_id, collection, id): the owning app,
|
|
5
|
+
the collection and the document id, so ids never collide across apps. */
|
|
4
6
|
class PostgresJsonStore extends JsonStore {
|
|
5
7
|
|
|
6
|
-
constructor(private pool : Pool, collection : string, auditor : Auditor = new NoOpAuditor()) {
|
|
8
|
+
constructor(private pool : Pool, private appId : string, collection : string, auditor : Auditor = new NoOpAuditor()) {
|
|
7
9
|
super(auditor, collection);
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
protected async saveInternal(id : string, document : any) : Promise<void> {
|
|
11
13
|
await this.pool.query(
|
|
12
|
-
`INSERT INTO documents (collection, id, document) VALUES ($1, $2, $3)
|
|
13
|
-
ON CONFLICT (collection, id) DO UPDATE SET document = EXCLUDED.document`,
|
|
14
|
-
[this.collection, id, document],
|
|
14
|
+
`INSERT INTO documents (app_id, collection, id, document) VALUES ($1, $2, $3, $4)
|
|
15
|
+
ON CONFLICT (app_id, collection, id) DO UPDATE SET document = EXCLUDED.document`,
|
|
16
|
+
[this.appId, this.collection, id, document],
|
|
15
17
|
);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
protected async retrieveInternal(id : string) : Promise<any> {
|
|
19
21
|
const result = await this.pool.query(
|
|
20
|
-
"SELECT document FROM documents WHERE
|
|
21
|
-
[this.collection, id],
|
|
22
|
+
"SELECT document FROM documents WHERE app_id = $1 AND collection = $2 AND id = $3",
|
|
23
|
+
[this.appId, this.collection, id],
|
|
22
24
|
);
|
|
23
25
|
if (result.rowCount === 0) throw new Error(`No document found with id "${id}"`);
|
|
24
26
|
|
|
@@ -26,13 +28,14 @@ class PostgresJsonStore extends JsonStore {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
protected async deleteInternal(id : string) : Promise<void> {
|
|
29
|
-
await this.pool.query("DELETE FROM documents WHERE
|
|
31
|
+
await this.pool.query("DELETE FROM documents WHERE app_id = $1 AND collection = $2 AND id = $3",
|
|
32
|
+
[this.appId, this.collection, id]);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
protected async listInternal(pageSize : number = 100, page : number = 0) : Promise<Array<any>> {
|
|
33
36
|
const result = await this.pool.query(
|
|
34
|
-
"SELECT document FROM documents WHERE
|
|
35
|
-
[this.collection, pageSize, page * pageSize],
|
|
37
|
+
"SELECT document FROM documents WHERE app_id = $1 AND collection = $2 ORDER BY inserted_at LIMIT $3 OFFSET $4",
|
|
38
|
+
[this.appId, this.collection, pageSize, page * pageSize],
|
|
36
39
|
);
|
|
37
40
|
|
|
38
41
|
return result.rows.map(row => row.document);
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -25,6 +25,14 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
25
25
|
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'");
|
|
26
26
|
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS killed BOOLEAN NOT NULL DEFAULT false");
|
|
27
27
|
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'");
|
|
28
|
+
// A workflow is keyed by the composite (app_id, workflow_id). Older rows
|
|
29
|
+
// stored "appId/workflowId" in workflow_id; split that once into the two
|
|
30
|
+
// columns (best effort - a bare workflow id with no app is left as-is).
|
|
31
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
32
|
+
await pool.query(`UPDATE jobs
|
|
33
|
+
SET app_id = split_part(workflow_id, '/', 1),
|
|
34
|
+
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
35
|
+
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
|
|
28
36
|
// The await a job is parked on; its metadata is normalised here rather than on the job.
|
|
29
37
|
await pool.query(`
|
|
30
38
|
CREATE TABLE IF NOT EXISTS awaits (
|
|
@@ -36,13 +44,24 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
36
44
|
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS waiting_for UUID REFERENCES awaits(id) ON DELETE SET NULL");
|
|
37
45
|
await pool.query(`
|
|
38
46
|
CREATE TABLE IF NOT EXISTS documents (
|
|
47
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
39
48
|
collection TEXT NOT NULL,
|
|
40
49
|
id TEXT NOT NULL,
|
|
41
50
|
document JSONB NOT NULL,
|
|
42
51
|
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY,
|
|
43
|
-
PRIMARY KEY (collection, id)
|
|
52
|
+
PRIMARY KEY (app_id, collection, id)
|
|
44
53
|
)
|
|
45
54
|
`);
|
|
55
|
+
// Documents are owned by an app: widen the key to (app_id, collection, id).
|
|
56
|
+
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS app_id TEXT NOT NULL DEFAULT ''");
|
|
57
|
+
await pool.query(`DO $$ BEGIN
|
|
58
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
|
|
59
|
+
WHERE table_name = 'documents' AND constraint_name = 'documents_pkey'
|
|
60
|
+
AND column_name = 'app_id') THEN
|
|
61
|
+
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_pkey;
|
|
62
|
+
ALTER TABLE documents ADD PRIMARY KEY (app_id, collection, id);
|
|
63
|
+
END IF;
|
|
64
|
+
END $$`);
|
|
46
65
|
await pool.query(`
|
|
47
66
|
CREATE TABLE IF NOT EXISTS queue (
|
|
48
67
|
position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
@@ -52,6 +71,11 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
52
71
|
leased_until TIMESTAMPTZ
|
|
53
72
|
)
|
|
54
73
|
`);
|
|
74
|
+
await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
75
|
+
await pool.query(`UPDATE queue
|
|
76
|
+
SET app_id = split_part(workflow_id, '/', 1),
|
|
77
|
+
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
78
|
+
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
|
|
55
79
|
await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
|
|
56
80
|
await pool.query(`
|
|
57
81
|
CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
@@ -86,6 +110,8 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
86
110
|
`);
|
|
87
111
|
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_type TEXT");
|
|
88
112
|
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_id TEXT");
|
|
113
|
+
// The app that owns the audited resource; part of every resource's composite identity.
|
|
114
|
+
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
89
115
|
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction TEXT[]");
|
|
90
116
|
await pool.query(`
|
|
91
117
|
DO $$ BEGIN
|
|
@@ -7,6 +7,7 @@ import {TokenAuthenticator} from "../auth/TokenAuthenticator";
|
|
|
7
7
|
import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
|
|
8
8
|
import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
|
|
9
9
|
import {AppProxyHandler} from "./handlers/AppProxyHandler";
|
|
10
|
+
import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
|
|
10
11
|
import {AppsHandler} from "./handlers/AppsHandler";
|
|
11
12
|
import {AuditsHandler} from "./handlers/AuditsHandler";
|
|
12
13
|
import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
@@ -44,8 +45,8 @@ class HostingServer {
|
|
|
44
45
|
constructor(persistence : JobPersistence, queue : ConfirmableQueue,
|
|
45
46
|
registry : ConsumerRegistry = new ConsumerRegistry(),
|
|
46
47
|
buildLayer? : BuildLayer,
|
|
47
|
-
documentStoreFor? : (collection : string) => JsonStore,
|
|
48
|
-
|
|
48
|
+
documentStoreFor? : (appId : string, collection : string) => JsonStore,
|
|
49
|
+
secretStoreFor? : (appId : string) => SecretStore,
|
|
49
50
|
authenticator? : Authenticator,
|
|
50
51
|
cliAuthorizer? : CliAuthorizer,
|
|
51
52
|
tokenAuthenticator? : TokenAuthenticator,
|
|
@@ -59,7 +60,7 @@ class HostingServer {
|
|
|
59
60
|
const consumers = new ConsumersHandler(registry);
|
|
60
61
|
const stateMachines = new StateMachinesHandler(registry);
|
|
61
62
|
const documents = documentStoreFor && new DocumentsHandler(documentStoreFor);
|
|
62
|
-
const secrets =
|
|
63
|
+
const secrets = secretStoreFor && new SecretsHandler(secretStoreFor);
|
|
63
64
|
const audits = auditRecords && new AuditsHandler(auditRecords);
|
|
64
65
|
const sessions = new SessionsHandler();
|
|
65
66
|
|
|
@@ -84,6 +85,9 @@ class HostingServer {
|
|
|
84
85
|
publicRouter.registerApi("apps", new AppsHandler(buildLayer));
|
|
85
86
|
publicRouter.register("app", new AppProxyHandler(buildLayer));
|
|
86
87
|
}
|
|
88
|
+
// An unrouted absolute path carrying an app Referer is an app-internal
|
|
89
|
+
// link the proxy's prefix-stripping left bare; send it back to its app.
|
|
90
|
+
publicRouter.registerFallback(new AppLinkFallbackHandler());
|
|
87
91
|
|
|
88
92
|
const internalRouter = new Router();
|
|
89
93
|
internalRouter.register("ping", ping);
|
package/src/hosting/Request.ts
CHANGED
|
@@ -67,6 +67,12 @@ class Request {
|
|
|
67
67
|
return value === undefined ? undefined : String(value);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// The calling app's id, sent as an ambient header by the cloud clients, used
|
|
71
|
+
// to scope app-owned resources (documents, secrets) server-side.
|
|
72
|
+
get appId() : string | undefined {
|
|
73
|
+
return this.header("x-anbaric-app");
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
query(name : string) : string | undefined {
|
|
71
77
|
return this.parsed.searchParams.get(name) ?? undefined;
|
|
72
78
|
}
|
|
@@ -105,6 +111,11 @@ class Request {
|
|
|
105
111
|
this.response.end(script);
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
redirect(location : string, status : number = 302, headers : Record<string, string> = {}) : void {
|
|
115
|
+
this.response.writeHead(status, { location, ...headers });
|
|
116
|
+
this.response.end();
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
notFound() : void {
|
|
109
120
|
this.reply(404, { error: "Not found" });
|
|
110
121
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {appInternalRedirect} from "anbaric-web";
|
|
2
|
+
import {Request} from "../Request";
|
|
3
|
+
import {RequestHandler} from "../RequestHandler";
|
|
4
|
+
|
|
5
|
+
/* The public router's fallback for an otherwise-unrouted path. When the request
|
|
6
|
+
is an app-internal absolute link (identified by its Referer), it is redirected
|
|
7
|
+
back onto its app; anything else is a genuine 404. A 307 is used so a form
|
|
8
|
+
POST to an absolute action keeps its method and body across the redirect.
|
|
9
|
+
|
|
10
|
+
The redirect must never be cached: the same root path (e.g. "/thing") maps to
|
|
11
|
+
different apps depending on the Referer, so a stored mapping would send one
|
|
12
|
+
app's request to another. `no-store` forbids caching outright and
|
|
13
|
+
`Vary: Referer` states the dependency for any cache that stores it anyway. */
|
|
14
|
+
class AppLinkFallbackHandler implements RequestHandler {
|
|
15
|
+
|
|
16
|
+
async handle(request : Request) : Promise<void> {
|
|
17
|
+
const target = appInternalRedirect(request.url.pathname, request.url.search, request.header("referer"));
|
|
18
|
+
if (target !== undefined) {
|
|
19
|
+
return request.redirect(target, 307, { "cache-control": "no-store", "vary": "Referer" });
|
|
20
|
+
}
|
|
21
|
+
request.notFound();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { AppLinkFallbackHandler };
|
|
@@ -34,6 +34,7 @@ class AuditsHandler implements RequestHandler {
|
|
|
34
34
|
private async handleQuery(request : Request) : Promise<void> {
|
|
35
35
|
const interaction = request.query("interaction");
|
|
36
36
|
const records = await this.auditRecords.list({
|
|
37
|
+
appId: request.query("appId"),
|
|
37
38
|
resourceType: request.query("resourceType"),
|
|
38
39
|
resourceId: request.query("resourceId"),
|
|
39
40
|
actorId: request.query("actorId"),
|
|
@@ -11,8 +11,8 @@ class ConsumersHandler implements RequestHandler {
|
|
|
11
11
|
|
|
12
12
|
switch (request.method) {
|
|
13
13
|
case "POST": {
|
|
14
|
-
const { workflowId, url } = await request.body();
|
|
15
|
-
this.registry.register(workflowId, url);
|
|
14
|
+
const { appId, workflowId, url } = await request.body();
|
|
15
|
+
this.registry.register(appId, workflowId, url);
|
|
16
16
|
return request.reply(204);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -4,12 +4,14 @@ import {RequestHandler} from "../RequestHandler";
|
|
|
4
4
|
|
|
5
5
|
class DocumentsHandler implements RequestHandler {
|
|
6
6
|
|
|
7
|
-
constructor(private storeFor : (collection : string) => JsonStore) {}
|
|
7
|
+
constructor(private storeFor : (appId : string, collection : string) => JsonStore) {}
|
|
8
8
|
|
|
9
9
|
async handle(request : Request) : Promise<void> {
|
|
10
10
|
if (!request.id) return request.notFound();
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Documents are owned by the calling app (the ambient app header); the
|
|
13
|
+
// store is scoped to (appId, collection) so ids never collide across apps.
|
|
14
|
+
const store = this.storeFor(request.appId ?? "", request.id);
|
|
13
15
|
if (request.subresource) return this.handleDocument(request, store, request.subresource);
|
|
14
16
|
return this.handleCollection(request, store);
|
|
15
17
|
}
|
|
@@ -17,20 +17,20 @@ class QueueHandler implements RequestHandler {
|
|
|
17
17
|
|
|
18
18
|
switch (request.id) {
|
|
19
19
|
case "enqueue": {
|
|
20
|
-
const { jobId, workflowId } = await request.body();
|
|
21
|
-
await this.queue.enqueue(jobId, workflowId);
|
|
20
|
+
const { jobId, appId, workflowId } = await request.body();
|
|
21
|
+
await this.queue.enqueue(jobId, appId, workflowId);
|
|
22
22
|
return request.reply(204);
|
|
23
23
|
}
|
|
24
24
|
case "schedule": {
|
|
25
|
-
const { jobId, workflowId, due } = await request.body();
|
|
26
|
-
await this.queue.schedule(jobId, workflowId, new Date(due));
|
|
25
|
+
const { jobId, appId, workflowId, due } = await request.body();
|
|
26
|
+
await this.queue.schedule(jobId, appId, workflowId, new Date(due));
|
|
27
27
|
return request.reply(204);
|
|
28
28
|
}
|
|
29
29
|
case "dequeue":
|
|
30
30
|
return request.reply(200, { messages: await this.queue.dequeueSome() });
|
|
31
31
|
case "confirm": {
|
|
32
|
-
const { jobId, workflowId, position } = await request.body();
|
|
33
|
-
await this.queue.confirm({ jobId, workflowId, position });
|
|
32
|
+
const { jobId, appId, workflowId, position } = await request.body();
|
|
33
|
+
await this.queue.confirm({ jobId, appId, workflowId, position });
|
|
34
34
|
return request.reply(204);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -4,35 +4,37 @@ import {RequestHandler} from "../RequestHandler";
|
|
|
4
4
|
|
|
5
5
|
class SecretsHandler implements RequestHandler {
|
|
6
6
|
|
|
7
|
-
constructor(private
|
|
7
|
+
constructor(private storeFor : (appId : string) => SecretStore) {}
|
|
8
8
|
|
|
9
9
|
async handle(request : Request) : Promise<void> {
|
|
10
10
|
if (request.subresource) return request.notFound();
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Secrets are owned by the calling app (the ambient app header).
|
|
12
|
+
const store = this.storeFor(request.appId ?? "");
|
|
13
|
+
if (request.id) return this.handleSecret(request, store, request.id);
|
|
14
|
+
return this.handleCollection(request, store);
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
private async handleSecret(request : Request, name : string) : Promise<void> {
|
|
17
|
+
private async handleSecret(request : Request, store : SecretStore, name : string) : Promise<void> {
|
|
16
18
|
switch (request.method) {
|
|
17
19
|
case "PUT": {
|
|
18
20
|
const { value } = await request.body();
|
|
19
21
|
if (typeof value !== "string") return request.reply(400, { error: "Expected a body of { value : string }" });
|
|
20
|
-
await
|
|
22
|
+
await store.create(SystemActor.actor, name, value);
|
|
21
23
|
return request.reply(204);
|
|
22
24
|
}
|
|
23
25
|
case "GET":
|
|
24
|
-
return request.reply(200, { value: await
|
|
26
|
+
return request.reply(200, { value: await store.retrieve(name, SystemActor.actor) });
|
|
25
27
|
case "DELETE":
|
|
26
|
-
await
|
|
28
|
+
await store.delete(name, SystemActor.actor);
|
|
27
29
|
return request.reply(204);
|
|
28
30
|
}
|
|
29
31
|
request.notFound();
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
private async handleCollection(request : Request) : Promise<void> {
|
|
34
|
+
private async handleCollection(request : Request, store : SecretStore) : Promise<void> {
|
|
33
35
|
switch (request.method) {
|
|
34
36
|
case "GET":
|
|
35
|
-
return request.reply(200, await
|
|
37
|
+
return request.reply(200, await store.list(SystemActor.actor));
|
|
36
38
|
}
|
|
37
39
|
request.notFound();
|
|
38
40
|
}
|
|
@@ -39,6 +39,15 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// A state-changing request with no valid session, bound for an
|
|
43
|
+
// authenticator that redirects to an interactive login, would have its
|
|
44
|
+
// body silently discarded across that redirect. Fail it loudly instead
|
|
45
|
+
// so the client can re-authenticate (a fresh GET) and retry.
|
|
46
|
+
if (this.isStateChanging(request.method) && this.authenticator?.redirectsToLoginOnFailure() && !request.handled) {
|
|
47
|
+
request.reply(401, { error: "Your session has expired or you are not signed in. Reload the page and try again." });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
if (!this.authenticator) return true;
|
|
43
52
|
|
|
44
53
|
const authenticated = await this.authenticator.authenticate(request.session, request.raw, request.rawResponse);
|
|
@@ -51,6 +60,10 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
51
60
|
return this.authorized(request);
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
private isStateChanging(method : string) : boolean {
|
|
64
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
private async authorized(request : Request) : Promise<boolean> {
|
|
55
68
|
if (!this.authenticator || !request.user) return true;
|
|
56
69
|
|
package/src/main.ts
CHANGED
|
@@ -61,9 +61,15 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
|
|
|
61
61
|
})
|
|
62
62
|
: undefined;
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// Secrets are owned by an app: each app gets its own namespace. AWS-backed
|
|
65
|
+
// stores fold the app into their name prefix (stateless, built per request);
|
|
66
|
+
// the in-memory local store is cached per app so its data persists.
|
|
67
|
+
const secretsManagerClient = process.env.AWS_REGION ? new SecretsManagerClient({}) : undefined;
|
|
68
|
+
const localSecretStores = new Map<string, InMemorySecretStore>();
|
|
69
|
+
const secretStoreFor = (appId : string) : SecretStore =>
|
|
70
|
+
secretsManagerClient
|
|
71
|
+
? new SecretsManagerSecretStore(secretsManagerClient, `anbaric/${appId}/`)
|
|
72
|
+
: localSecretStores.get(appId) ?? localSecretStores.set(appId, new InMemorySecretStore()).get(appId)!;
|
|
67
73
|
|
|
68
74
|
const authenticator = await loadAuthenticator(process.env.ANBARIC_AUTHENTICATOR);
|
|
69
75
|
const cliKeyStore = process.env.ANBARIC_CLI_KEY_LOOKUP_URL
|
|
@@ -75,7 +81,7 @@ const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBAR
|
|
|
75
81
|
const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "anbaric-plugins/state-machines");
|
|
76
82
|
|
|
77
83
|
const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
|
|
78
|
-
(collection) => new PostgresJsonStore(pool, collection),
|
|
84
|
+
(appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
|
|
79
85
|
tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins);
|
|
80
86
|
const port = await server.listen(hostingPort);
|
|
81
87
|
const internal = await server.listenInternal(internalPort);
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
class ConsumerRegistry {
|
|
2
2
|
|
|
3
|
-
private consumers = new Map<string, string>();
|
|
3
|
+
private consumers = new Map<string, { appId? : string, workflowId : string, url : string }>();
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Consumers are keyed by the (appId, workflowId) composite, encoded as a
|
|
6
|
+
// tuple so an app and a machine id can never collide.
|
|
7
|
+
private key(appId : string | undefined, workflowId : string) : string {
|
|
8
|
+
return JSON.stringify([appId || null, workflowId]);
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
register(appId : string | undefined, workflowId : string, url : string) : void {
|
|
12
|
+
this.consumers.set(this.key(appId, workflowId), { appId, workflowId, url });
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
+
lookup(appId : string | undefined, workflowId : string) : string | undefined {
|
|
16
|
+
return this.consumers.get(this.key(appId, workflowId))?.url;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
list() : Array<{ appId? : string, workflowId : string, url : string }> {
|
|
20
|
+
return Array.from(this.consumers.values());
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
}
|
|
@@ -28,9 +28,9 @@ class Dispatcher {
|
|
|
28
28
|
const byConsumerUrl = new Map<string, Array<QueueMessage>>();
|
|
29
29
|
|
|
30
30
|
for (const message of messages) {
|
|
31
|
-
const url = this.registry.lookup(message.workflowId);
|
|
31
|
+
const url = this.registry.lookup(message.appId, message.workflowId);
|
|
32
32
|
if (!url) {
|
|
33
|
-
await this.queue.enqueue(message.jobId, message.workflowId);
|
|
33
|
+
await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
36
|
byConsumerUrl.set(url, [...(byConsumerUrl.get(url) ?? []), message]);
|
|
@@ -54,7 +54,7 @@ class Dispatcher {
|
|
|
54
54
|
if (!response.ok) throw new Error(`Consumer at ${url} responded with status ${response.status}`);
|
|
55
55
|
} catch {
|
|
56
56
|
for (const message of batch) {
|
|
57
|
-
await this.queue.enqueue(message.jobId, message.workflowId);
|
|
57
|
+
await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -9,12 +9,12 @@ class PostgresQueue implements ConfirmableQueue {
|
|
|
9
9
|
|
|
10
10
|
constructor(private pool : Pool) {}
|
|
11
11
|
|
|
12
|
-
async enqueue(jobId : string, workflowId : string) : Promise<void> {
|
|
13
|
-
await this.pool.query("INSERT INTO queue (job_id, workflow_id) VALUES ($1, $2)", [jobId, workflowId]);
|
|
12
|
+
async enqueue(jobId : string, appId : string | undefined, workflowId : string) : Promise<void> {
|
|
13
|
+
await this.pool.query("INSERT INTO queue (job_id, app_id, workflow_id) VALUES ($1, $2, $3)", [jobId, appId || null, workflowId]);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
async schedule(jobId : string, workflowId : string, due : Date) : Promise<void> {
|
|
17
|
-
await this.pool.query("INSERT INTO queue (job_id, workflow_id, due) VALUES ($1, $2, $3)", [jobId, workflowId, due]);
|
|
16
|
+
async schedule(jobId : string, appId : string | undefined, workflowId : string, due : Date) : Promise<void> {
|
|
17
|
+
await this.pool.query("INSERT INTO queue (job_id, app_id, workflow_id, due) VALUES ($1, $2, $3, $4)", [jobId, appId || null, workflowId, due]);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async dequeueSome() : Promise<Array<QueueMessage>> {
|
|
@@ -28,7 +28,7 @@ class PostgresQueue implements ConfirmableQueue {
|
|
|
28
28
|
LIMIT $1
|
|
29
29
|
FOR UPDATE SKIP LOCKED
|
|
30
30
|
)
|
|
31
|
-
RETURNING job_id, workflow_id, due, position`,
|
|
31
|
+
RETURNING job_id, app_id, workflow_id, due, position`,
|
|
32
32
|
[DEQUEUE_BATCH_SIZE],
|
|
33
33
|
);
|
|
34
34
|
|
|
@@ -39,7 +39,7 @@ class PostgresQueue implements ConfirmableQueue {
|
|
|
39
39
|
if (!b.due) return 1;
|
|
40
40
|
return a.due.getTime() - b.due.getTime() || a.position - b.position;
|
|
41
41
|
})
|
|
42
|
-
.map(row => ({ jobId: row.job_id, workflowId: row.workflow_id, position: Number(row.position) }));
|
|
42
|
+
.map(row => ({ jobId: row.job_id, appId: row.app_id ?? undefined, workflowId: row.workflow_id, position: Number(row.position) }));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
async confirm(message : QueueMessage) : Promise<void> {
|