anbaric-cloud-hosting 1.32.0 → 1.34.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.32.0",
3
+ "version": "1.34.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.32.0",
22
- "anbaric-plugins": "^1.32.0",
23
- "anbaric-tsapi": "^1.32.0",
24
- "anbaric-web": "^1.32.0",
21
+ "anbaric-data-store": "^1.34.0",
22
+ "anbaric-plugins": "^1.34.0",
23
+ "anbaric-tsapi": "^1.34.0",
24
+ "anbaric-web": "^1.34.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.32.0",
32
- "anbaric-state-machine": "^1.32.0",
31
+ "anbaric-impl-cloud": "^1.34.0",
32
+ "anbaric-state-machine": "^1.34.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -107,6 +107,7 @@ class DockerBuildLayer extends BaseBuildLayer {
107
107
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
108
108
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
109
109
  "--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
110
+ "--env", "ANBARIC_ENTITLEMENTS_TYPE=cloud",
110
111
  "--env", "ANBARIC_NOTIFIER_TYPE=cloud",
111
112
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
112
113
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
@@ -197,6 +197,7 @@ class FargateBuildLayer extends BaseBuildLayer {
197
197
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
198
198
  { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
199
199
  { name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
200
+ { name: "ANBARIC_ENTITLEMENTS_TYPE", value: "cloud" },
200
201
  { name: "ANBARIC_NOTIFIER_TYPE", value: "cloud" },
201
202
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
202
203
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
@@ -0,0 +1,29 @@
1
+ import {User} from "./User";
2
+ import {DirectoryUser, UserDirectory} from "./UserDirectory";
3
+
4
+ class InMemoryUserDirectory implements UserDirectory {
5
+
6
+ private users = new Map<string, DirectoryUser>();
7
+
8
+ async record(user : User) : Promise<void> {
9
+ const now = new Date().toISOString();
10
+ const existing = this.users.get(user.id);
11
+ this.users.set(user.id, {
12
+ id: user.id,
13
+ name: user.name ?? "",
14
+ email: user.email ?? "",
15
+ picture: user.picture,
16
+ firstSeenAt: existing?.firstSeenAt ?? now,
17
+ lastSeenAt: now,
18
+ });
19
+ }
20
+
21
+ async list() : Promise<Array<DirectoryUser>> {
22
+ return Array.from(this.users.values())
23
+ .map(user => ({ ...user }))
24
+ .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
25
+ }
26
+
27
+ }
28
+
29
+ export { InMemoryUserDirectory }
@@ -42,6 +42,7 @@ class SessionSigner {
42
42
  roles: user.roles.map(role => role.id),
43
43
  name: user.name,
44
44
  picture: user.picture,
45
+ email: user.email,
45
46
  exp: nowSeconds() + this.ttlSeconds,
46
47
  };
47
48
  const payload = base64url(JSON.stringify(claims));
@@ -54,7 +55,7 @@ class SessionSigner {
54
55
  const [payload, signature] = token.split(".");
55
56
  if (!payload || !signature || !this.signatureMatches(payload, signature)) return undefined;
56
57
 
57
- let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, exp? : unknown };
58
+ let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, exp? : unknown };
58
59
  try {
59
60
  claims = JSON.parse(Buffer.from(payload, "base64url").toString());
60
61
  } catch {
@@ -68,7 +69,8 @@ class SessionSigner {
68
69
  const tenant = typeof claims.tenant === "string" ? new Tenant(claims.tenant) : undefined;
69
70
  const name = typeof claims.name === "string" ? claims.name : undefined;
70
71
  const picture = typeof claims.picture === "string" ? claims.picture : undefined;
71
- return [new User(claims.sub, roles, [], name, picture), tenant];
72
+ const email = typeof claims.email === "string" ? claims.email : undefined;
73
+ return [new User(claims.sub, roles, [], name, picture, email), tenant];
72
74
  }
73
75
 
74
76
  // Sets (or, for a still-valid session, refreshes) the session cookie.
@@ -0,0 +1,25 @@
1
+ import {User} from "./User";
2
+
3
+ type DirectoryUser = {
4
+
5
+ id : string,
6
+ name : string,
7
+ email : string,
8
+ picture? : string,
9
+ firstSeenAt : string,
10
+ lastSeenAt : string,
11
+
12
+ };
13
+
14
+ /* Everyone who has signed in to this tenant, as the platform saw them at
15
+ their last sign-in. A tenant-local directory: it needs no identity-provider
16
+ organisation, so it works for a single-owner tenant as much as a team. */
17
+ interface UserDirectory {
18
+
19
+ record(user : User) : Promise<void>;
20
+
21
+ list() : Promise<Array<DirectoryUser>>;
22
+
23
+ }
24
+
25
+ export type { UserDirectory, DirectoryUser }
@@ -0,0 +1,41 @@
1
+ import {Entitlements} from "anbaric-tsapi";
2
+
3
+ /* An appId of null means global: a global definition is one no app owns
4
+ (console-created), a global grant satisfies the check from any app. */
5
+ type EntitlementDefinition = {
6
+
7
+ appId : string | null,
8
+ entitlementId : string,
9
+ notes : string,
10
+
11
+ };
12
+
13
+ type EntitlementGrant = {
14
+
15
+ id : string,
16
+ userId : string,
17
+ appId : string | null,
18
+ entitlementId : string,
19
+ notes : string,
20
+ grantedAt : string,
21
+ grantedBy : string,
22
+
23
+ };
24
+
25
+ /* The platform's view of entitlements: what apps can do (register, has) plus
26
+ the management the console performs on their behalf. */
27
+ interface EntitlementStore extends Entitlements {
28
+
29
+ defineGlobal(entitlementId : string, notes : string) : Promise<void>;
30
+
31
+ listDefinitions() : Promise<Array<EntitlementDefinition>>;
32
+
33
+ grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant>;
34
+
35
+ revoke(grantId : string) : Promise<boolean>;
36
+
37
+ listGrants(userId? : string) : Promise<Array<EntitlementGrant>>;
38
+
39
+ }
40
+
41
+ export type { EntitlementStore, EntitlementDefinition, EntitlementGrant }
@@ -0,0 +1,55 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
3
+
4
+ class InMemoryEntitlementStore implements EntitlementStore {
5
+
6
+ private definitions = new Map<string, EntitlementDefinition>();
7
+ private grants = new Map<string, EntitlementGrant>();
8
+
9
+ constructor() {
10
+ this.definitions.set(this.key(null, "access"), { appId: null, entitlementId: "access", notes: "Global access" });
11
+ }
12
+
13
+ async register(appId : string, entitlementId : string, notes : string = "") : Promise<void> {
14
+ this.definitions.set(this.key(appId, entitlementId), { appId, entitlementId, notes });
15
+ }
16
+
17
+ async has(appId : string, userId : string, entitlementId : string) : Promise<boolean> {
18
+ return Array.from(this.grants.values()).some(grant =>
19
+ grant.userId === userId && grant.entitlementId === entitlementId &&
20
+ (grant.appId === null || grant.appId === appId));
21
+ }
22
+
23
+ async defineGlobal(entitlementId : string, notes : string) : Promise<void> {
24
+ this.definitions.set(this.key(null, entitlementId), { appId: null, entitlementId, notes });
25
+ }
26
+
27
+ async listDefinitions() : Promise<Array<EntitlementDefinition>> {
28
+ return Array.from(this.definitions.values()).map(definition => ({ ...definition }));
29
+ }
30
+
31
+ async grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant> {
32
+ const grant : EntitlementGrant = {
33
+ id: randomUUID(), userId, appId, entitlementId, notes, grantedBy, grantedAt: new Date().toISOString(),
34
+ };
35
+ this.grants.set(grant.id, grant);
36
+ return { ...grant };
37
+ }
38
+
39
+ async revoke(grantId : string) : Promise<boolean> {
40
+ return this.grants.delete(grantId);
41
+ }
42
+
43
+ async listGrants(userId? : string) : Promise<Array<EntitlementGrant>> {
44
+ return Array.from(this.grants.values())
45
+ .filter(grant => userId === undefined || grant.userId === userId)
46
+ .map(grant => ({ ...grant }));
47
+ }
48
+
49
+ private key(appId : string | null, entitlementId : string) : string {
50
+ return `${appId ?? ""}/${entitlementId}`;
51
+ }
52
+
53
+ }
54
+
55
+ export { InMemoryEntitlementStore }
@@ -0,0 +1,81 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {Pool} from "pg";
3
+ import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
4
+
5
+ class PostgresEntitlementStore implements EntitlementStore {
6
+
7
+ constructor(private pool : Pool) {}
8
+
9
+ async register(appId : string, entitlementId : string, notes : string = "") : Promise<void> {
10
+ await this.upsertDefinition(appId, entitlementId, notes);
11
+ }
12
+
13
+ async has(appId : string, userId : string, entitlementId : string) : Promise<boolean> {
14
+ const result = await this.pool.query(
15
+ `SELECT 1 FROM entitlement_grants
16
+ WHERE user_id = $1 AND entitlement_id = $2 AND (app_id = $3 OR app_id IS NULL)
17
+ LIMIT 1`,
18
+ [userId, entitlementId, appId]);
19
+ return (result.rowCount ?? 0) > 0;
20
+ }
21
+
22
+ async defineGlobal(entitlementId : string, notes : string) : Promise<void> {
23
+ await this.upsertDefinition(null, entitlementId, notes);
24
+ }
25
+
26
+ async listDefinitions() : Promise<Array<EntitlementDefinition>> {
27
+ const result = await this.pool.query(
28
+ `SELECT app_id, entitlement_id, notes FROM entitlements
29
+ ORDER BY app_id NULLS FIRST, entitlement_id`);
30
+ return result.rows.map(row => ({ appId: row.app_id, entitlementId: row.entitlement_id, notes: row.notes }));
31
+ }
32
+
33
+ async grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant> {
34
+ const id = randomUUID();
35
+ const result = await this.pool.query(
36
+ `INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
37
+ VALUES ($1, $2, $3, $4, $5, $6)
38
+ RETURNING granted_at`,
39
+ [id, userId, appId, entitlementId, notes, grantedBy]);
40
+ return { id, userId, appId, entitlementId, notes, grantedBy, grantedAt: this.iso(result.rows[0]?.granted_at) };
41
+ }
42
+
43
+ async revoke(grantId : string) : Promise<boolean> {
44
+ const result = await this.pool.query("DELETE FROM entitlement_grants WHERE id = $1", [grantId]);
45
+ return (result.rowCount ?? 0) > 0;
46
+ }
47
+
48
+ async listGrants(userId? : string) : Promise<Array<EntitlementGrant>> {
49
+ const result = userId === undefined
50
+ ? await this.pool.query(
51
+ `SELECT id, user_id, app_id, entitlement_id, notes, granted_at, granted_by
52
+ FROM entitlement_grants ORDER BY granted_at DESC`)
53
+ : await this.pool.query(
54
+ `SELECT id, user_id, app_id, entitlement_id, notes, granted_at, granted_by
55
+ FROM entitlement_grants WHERE user_id = $1 ORDER BY granted_at DESC`, [userId]);
56
+ return result.rows.map(row => ({
57
+ id: row.id,
58
+ userId: row.user_id,
59
+ appId: row.app_id,
60
+ entitlementId: row.entitlement_id,
61
+ notes: row.notes,
62
+ grantedAt: this.iso(row.granted_at),
63
+ grantedBy: row.granted_by,
64
+ }));
65
+ }
66
+
67
+ private async upsertDefinition(appId : string | null, entitlementId : string, notes : string) : Promise<void> {
68
+ await this.pool.query(
69
+ `INSERT INTO entitlements (app_id, entitlement_id, notes)
70
+ VALUES ($1, $2, $3)
71
+ ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO UPDATE SET notes = EXCLUDED.notes`,
72
+ [appId, entitlementId, notes]);
73
+ }
74
+
75
+ private iso(value : unknown) : string {
76
+ return value instanceof Date ? value.toISOString() : String(value ?? new Date().toISOString());
77
+ }
78
+
79
+ }
80
+
81
+ export { PostgresEntitlementStore }
@@ -0,0 +1,39 @@
1
+ import {Pool} from "pg";
2
+ import {User} from "../auth/User";
3
+ import {DirectoryUser, UserDirectory} from "../auth/UserDirectory";
4
+
5
+ class PostgresUserDirectory implements UserDirectory {
6
+
7
+ constructor(private pool : Pool) {}
8
+
9
+ async record(user : User) : Promise<void> {
10
+ await this.pool.query(
11
+ `INSERT INTO anbaric_system.users (id, name, email, picture)
12
+ VALUES ($1, $2, $3, $4)
13
+ ON CONFLICT (id) DO UPDATE
14
+ SET name = EXCLUDED.name, email = EXCLUDED.email, picture = EXCLUDED.picture, last_seen = now()`,
15
+ [user.id, user.name ?? "", user.email ?? "", user.picture ?? null]);
16
+ }
17
+
18
+ async list() : Promise<Array<DirectoryUser>> {
19
+ const result = await this.pool.query(
20
+ `SELECT id, name, email, picture, first_seen, last_seen
21
+ FROM anbaric_system.users
22
+ ORDER BY NULLIF(name, '') NULLS LAST, id`);
23
+ return result.rows.map(row => ({
24
+ id: row.id,
25
+ name: row.name,
26
+ email: row.email,
27
+ picture: row.picture ?? undefined,
28
+ firstSeenAt: this.iso(row.first_seen),
29
+ lastSeenAt: this.iso(row.last_seen),
30
+ }));
31
+ }
32
+
33
+ private iso(value : unknown) : string {
34
+ return value instanceof Date ? value.toISOString() : String(value ?? "");
35
+ }
36
+
37
+ }
38
+
39
+ export { PostgresUserDirectory }
@@ -112,6 +112,38 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
112
112
  `);
113
113
  await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS parent_slug TEXT");
114
114
  await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0");
115
+ await pool.query(`
116
+ CREATE TABLE IF NOT EXISTS entitlements (
117
+ app_id TEXT,
118
+ entitlement_id TEXT NOT NULL,
119
+ notes TEXT NOT NULL DEFAULT '',
120
+ registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
121
+ )
122
+ `);
123
+ await pool.query(`
124
+ CREATE UNIQUE INDEX IF NOT EXISTS entitlements_key
125
+ ON entitlements (COALESCE(app_id, ''), entitlement_id)
126
+ `);
127
+ await pool.query(`
128
+ CREATE TABLE IF NOT EXISTS entitlement_grants (
129
+ id TEXT PRIMARY KEY,
130
+ user_id TEXT NOT NULL,
131
+ app_id TEXT,
132
+ entitlement_id TEXT NOT NULL,
133
+ notes TEXT NOT NULL DEFAULT '',
134
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
135
+ granted_by TEXT NOT NULL
136
+ )
137
+ `);
138
+ await pool.query(`
139
+ CREATE INDEX IF NOT EXISTS entitlement_grants_lookup
140
+ ON entitlement_grants (user_id, entitlement_id)
141
+ `);
142
+ await pool.query(`
143
+ INSERT INTO entitlements (app_id, entitlement_id, notes)
144
+ VALUES (NULL, 'access', 'Global access')
145
+ ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO NOTHING
146
+ `);
115
147
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
116
148
  await pool.query(`
117
149
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -124,6 +156,16 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
124
156
  )
125
157
  `);
126
158
  await pool.query("ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT");
159
+ await pool.query(`
160
+ CREATE TABLE IF NOT EXISTS anbaric_system.users (
161
+ id TEXT PRIMARY KEY,
162
+ name TEXT NOT NULL DEFAULT '',
163
+ email TEXT NOT NULL DEFAULT '',
164
+ picture TEXT,
165
+ first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
166
+ last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
167
+ )
168
+ `);
127
169
  await pool.query(`
128
170
  DO $$ BEGIN
129
171
  CREATE TYPE anbaric_system.audit_interaction AS ENUM
@@ -13,9 +13,14 @@ import {AuditsHandler} from "./handlers/AuditsHandler";
13
13
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
14
14
  import {KeysHandler} from "./handlers/auth/KeysHandler";
15
15
  import {SessionsHandler} from "./handlers/auth/SessionsHandler";
16
+ import {UsersHandler} from "./handlers/auth/UsersHandler";
16
17
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
18
+ import {UserDirectory} from "../auth/UserDirectory";
17
19
  import {ConsumersHandler} from "./handlers/ConsumersHandler";
18
20
  import {DocumentsHandler} from "./handlers/DocumentsHandler";
21
+ import {EntitlementsAdminHandler} from "./handlers/EntitlementsAdminHandler";
22
+ import {EntitlementsHandler} from "./handlers/EntitlementsHandler";
23
+ import {EntitlementStore} from "../data-store/EntitlementStore";
19
24
  import {JobsHandler} from "./handlers/JobsHandler";
20
25
  import {FaviconHandler} from "./handlers/FaviconHandler";
21
26
  import {JobRunSchedulesHandler} from "./handlers/JobRunSchedulesHandler";
@@ -58,7 +63,9 @@ class HostingServer {
58
63
  auditRecords? : AuditRecordStore,
59
64
  plugins : Array<LoadedPlugin> = [],
60
65
  jobRunSchedules? : JobRunSchedulePersistence,
61
- notifier? : Notifier) {
66
+ notifier? : Notifier,
67
+ entitlements? : EntitlementStore,
68
+ userDirectory? : UserDirectory) {
62
69
  const pages = new PagesHandler();
63
70
  const ping = new PingHandler(tenant);
64
71
  const jobs = new JobsHandler(persistence);
@@ -87,6 +94,8 @@ class HostingServer {
87
94
  if (audits) publicRouter.registerApi("audits", audits);
88
95
  if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
89
96
  if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
97
+ if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
98
+ if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
90
99
  if (cliAuthorizer) {
91
100
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
92
101
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -111,6 +120,7 @@ class HostingServer {
111
120
  if (audits) internalRouter.registerApi("audits", audits);
112
121
  if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
113
122
  if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
123
+ if (entitlements) internalRouter.registerApi("entitlements", new EntitlementsHandler(entitlements));
114
124
 
115
125
  // The favicon is requested by the browser before anyone has signed in,
116
126
  // and by deployed apps' pages, so gating it behind a session would send
@@ -122,7 +132,7 @@ class HostingServer {
122
132
 
123
133
  this.publicServer = new Server(publicRouter, [
124
134
  new SessionMiddleware(),
125
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
135
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
126
136
  ]);
127
137
  this.internalServer = new Server(internalRouter);
128
138
  }
@@ -0,0 +1,52 @@
1
+ import {EntitlementStore} from "../../data-store/EntitlementStore";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* The console-facing side, served on the authenticated public entry point:
6
+ list definitions, create global ones, and grant or revoke entitlements to
7
+ users. Grants record who made them from the signed-in user. */
8
+ class EntitlementsAdminHandler implements RequestHandler {
9
+
10
+ constructor(private store : EntitlementStore) {}
11
+
12
+ async handle(request : Request) : Promise<void> {
13
+ const { method, id, subresource } = request;
14
+
15
+ if (method === "GET" && ! id) return request.reply(200, await this.store.listDefinitions());
16
+ if (method === "POST" && id === "definitions" && ! subresource) return this.defineGlobal(request);
17
+ if (method === "GET" && id === "grants" && ! subresource) {
18
+ return request.reply(200, await this.store.listGrants(request.query("userId")));
19
+ }
20
+ if (method === "POST" && id === "grants" && ! subresource) return this.grant(request);
21
+ if (method === "DELETE" && id === "grants" && subresource) return this.revoke(request, subresource);
22
+ request.notFound();
23
+ }
24
+
25
+ private async defineGlobal(request : Request) : Promise<void> {
26
+ const body = await request.body() as { entitlementId? : string, notes? : string } | undefined;
27
+ if (! body?.entitlementId) return request.reply(400, { error: "entitlementId is required" });
28
+
29
+ await this.store.defineGlobal(body.entitlementId, body.notes ?? "");
30
+ request.reply(204);
31
+ }
32
+
33
+ private async grant(request : Request) : Promise<void> {
34
+ const body = await request.body() as
35
+ { userId? : string, appId? : string | null, entitlementId? : string, notes? : string } | undefined;
36
+ if (! body?.userId || ! body.entitlementId) {
37
+ return request.reply(400, { error: "userId and entitlementId are required" });
38
+ }
39
+
40
+ const grantedBy = request.user?.id ?? "system";
41
+ const grant = await this.store.grant(body.userId, body.appId || null, body.entitlementId, body.notes ?? "", grantedBy);
42
+ request.reply(201, grant);
43
+ }
44
+
45
+ private async revoke(request : Request, grantId : string) : Promise<void> {
46
+ const removed = await this.store.revoke(grantId);
47
+ removed ? request.reply(204) : request.notFound();
48
+ }
49
+
50
+ }
51
+
52
+ export { EntitlementsAdminHandler }
@@ -0,0 +1,39 @@
1
+ import {Entitlements} from "anbaric-tsapi";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* The app-facing side, served on the internal entry point: register an
6
+ entitlement and check whether a user holds it. Both are scoped to the
7
+ calling app (the ambient app header), so an app can neither define a global
8
+ entitlement nor check as another app. */
9
+ class EntitlementsHandler implements RequestHandler {
10
+
11
+ constructor(private entitlements : Entitlements) {}
12
+
13
+ async handle(request : Request) : Promise<void> {
14
+ const appId = request.appId;
15
+ if (! appId) return request.reply(400, { error: "Missing the x-anbaric-app header" });
16
+
17
+ if (request.method === "POST" && ! request.id) return this.register(request, appId);
18
+ if (request.method === "GET" && request.id && request.subresource === "check") return this.check(request, appId);
19
+ request.notFound();
20
+ }
21
+
22
+ private async register(request : Request, appId : string) : Promise<void> {
23
+ const body = await request.body() as { entitlementId? : string, notes? : string } | undefined;
24
+ if (! body?.entitlementId) return request.reply(400, { error: "entitlementId is required" });
25
+
26
+ await this.entitlements.register(appId, body.entitlementId, body.notes ?? "");
27
+ request.reply(204);
28
+ }
29
+
30
+ private async check(request : Request, appId : string) : Promise<void> {
31
+ const userId = request.query("userId");
32
+ if (! userId) return request.reply(400, { error: "userId is required" });
33
+
34
+ request.reply(200, { has: await this.entitlements.has(appId, userId, request.id!) });
35
+ }
36
+
37
+ }
38
+
39
+ export { EntitlementsHandler }
@@ -0,0 +1,16 @@
1
+ import {UserDirectory} from "../../../auth/UserDirectory";
2
+ import {Request} from "../../Request";
3
+ import {RequestHandler} from "../../RequestHandler";
4
+
5
+ class UsersHandler implements RequestHandler {
6
+
7
+ constructor(private directory : UserDirectory) {}
8
+
9
+ async handle(request : Request) : Promise<void> {
10
+ if (request.method === "GET" && ! request.id) return request.reply(200, await this.directory.list());
11
+ request.notFound();
12
+ }
13
+
14
+ }
15
+
16
+ export { UsersHandler }
@@ -1,24 +1,32 @@
1
1
  import {Authenticator} from "../../auth/Authenticator";
2
2
  import {SessionSigner} from "../../auth/SessionSigner";
3
3
  import {TokenAuthenticator} from "../../auth/TokenAuthenticator";
4
+ import {User} from "../../auth/User";
5
+ import {UserDirectory} from "../../auth/UserDirectory";
4
6
  import {Middleware} from "../Middleware";
5
7
  import {Request} from "../Request";
6
8
 
7
9
  type OpenRequestPredicate = (request : Request) => boolean;
8
10
 
11
+ const DIRECTORY_REFRESH_MS = 60 * 60 * 1000;
12
+
9
13
  /* Decorates the request with its authenticated user and tenant - bearer
10
14
  tokens first, then a valid platform session cookie, then the authenticator -
11
15
  and enforces authorization. A valid platform session short-circuits the
12
16
  authenticator entirely (no identity-provider round-trip) and slides its
13
17
  expiry; a fresh authenticator login mints one. Requests matching the open
14
18
  predicate (ping, the one-time CLI key poll) pass through untouched, as does
15
- everything when no authenticator is configured. */
19
+ everything when no authenticator is configured. Browser-session users are
20
+ recorded in the tenant's user directory, at most once an hour each. */
16
21
  class AuthenticationMiddleware implements Middleware {
17
22
 
23
+ private recorded = new Map<string, number>();
24
+
18
25
  constructor(private authenticator? : Authenticator,
19
26
  private tokenAuthenticator? : TokenAuthenticator,
20
27
  private isOpen : OpenRequestPredicate = () => false,
21
- private sessionSigner : SessionSigner = new SessionSigner()) {}
28
+ private sessionSigner : SessionSigner = new SessionSigner(),
29
+ private userDirectory? : UserDirectory) {}
22
30
 
23
31
  async apply(request : Request) : Promise<boolean> {
24
32
  if (this.isOpen(request)) return true;
@@ -35,6 +43,7 @@ class AuthenticationMiddleware implements Middleware {
35
43
  if (session) {
36
44
  [request.user, request.tenant] = session;
37
45
  this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
46
+ await this.remember(request.user!);
38
47
  return this.authorized(request);
39
48
  }
40
49
  }
@@ -57,9 +66,26 @@ class AuthenticationMiddleware implements Middleware {
57
66
  if (this.sessionSigner.configured && !request.handled) {
58
67
  this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
59
68
  }
69
+ await this.remember(request.user!);
60
70
  return this.authorized(request);
61
71
  }
62
72
 
73
+ private async remember(user : User) : Promise<void> {
74
+ if (! this.userDirectory) return;
75
+
76
+ const now = Date.now();
77
+ const last = this.recorded.get(user.id) ?? 0;
78
+ if (now - last < DIRECTORY_REFRESH_MS) return;
79
+
80
+ this.recorded.set(user.id, now);
81
+ try {
82
+ await this.userDirectory.record(user);
83
+ } catch (error) {
84
+ this.recorded.delete(user.id);
85
+ console.error(`Could not record user "${user.id}" in the directory:`, error);
86
+ }
87
+ }
88
+
63
89
  private isStateChanging(method : string) : boolean {
64
90
  return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
65
91
  }