anbaric-cloud-hosting 1.33.0 → 1.35.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.33.0",
3
+ "version": "1.35.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.33.0",
22
- "anbaric-plugins": "^1.33.0",
23
- "anbaric-tsapi": "^1.33.0",
24
- "anbaric-web": "^1.33.0",
21
+ "anbaric-data-store": "^1.35.0",
22
+ "anbaric-plugins": "^1.35.0",
23
+ "anbaric-tsapi": "^1.35.0",
24
+ "anbaric-web": "^1.35.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.33.0",
32
- "anbaric-state-machine": "^1.33.0",
31
+ "anbaric-impl-cloud": "^1.35.0",
32
+ "anbaric-state-machine": "^1.35.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -0,0 +1,44 @@
1
+ import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
2
+
3
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
4
+
5
+ /* Talks to the central login's invitation endpoints for one tenant, with the
6
+ same central api key the platform already holds for CLI key lookups. */
7
+ class HttpMembershipService implements MembershipService {
8
+
9
+ constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
10
+ private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
11
+
12
+ async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
13
+ const response = await this.call("POST", "", { email, invitedBy });
14
+ return await response.json() as PendingInvitation;
15
+ }
16
+
17
+ async pending() : Promise<Array<PendingInvitation>> {
18
+ const response = await this.call("GET", "");
19
+ return await response.json() as Array<PendingInvitation>;
20
+ }
21
+
22
+ async revoke(token : string) : Promise<boolean> {
23
+ const response = await this.call("DELETE", `/${encodeURIComponent(token)}`, undefined, [404]);
24
+ return response.status === 204;
25
+ }
26
+
27
+ private async call(method : string, path : string, body? : unknown, tolerated : Array<number> = []) : Promise<Response> {
28
+ const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
29
+ if (body !== undefined) headers["content-type"] = "application/json";
30
+
31
+ const response = await this.fetchFn(
32
+ `${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/invitations${path}`,
33
+ { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
34
+
35
+ if (! response.ok && ! tolerated.includes(response.status)) {
36
+ const problem = await response.json().catch(() => ({})) as { error? : string };
37
+ throw new Error(problem.error ?? `The invitation request failed with status ${response.status}`);
38
+ }
39
+ return response;
40
+ }
41
+
42
+ }
43
+
44
+ export { HttpMembershipService }
@@ -0,0 +1,38 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
3
+
4
+ class InMemoryMembershipService implements MembershipService {
5
+
6
+ private invitations = new Map<string, PendingInvitation>();
7
+
8
+ constructor(private linkBase : string = "http://localhost/invitations") {}
9
+
10
+ async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
11
+ const token = randomUUID();
12
+ const now = new Date();
13
+ const invitation : PendingInvitation = {
14
+ token,
15
+ email: email.trim().toLowerCase(),
16
+ role: "member",
17
+ invitedBy: invitedBy.id,
18
+ invitedByName: invitedBy.name,
19
+ link: `${this.linkBase}/${token}`,
20
+ createdAt: now.toISOString(),
21
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
22
+ emailed: false,
23
+ };
24
+ this.invitations.set(token, invitation);
25
+ return { ...invitation };
26
+ }
27
+
28
+ async pending() : Promise<Array<PendingInvitation>> {
29
+ return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
30
+ }
31
+
32
+ async revoke(token : string) : Promise<boolean> {
33
+ return this.invitations.delete(token);
34
+ }
35
+
36
+ }
37
+
38
+ export { InMemoryMembershipService }
@@ -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 }
@@ -0,0 +1,35 @@
1
+ type PendingInvitation = {
2
+
3
+ token : string,
4
+ email : string,
5
+ role : string,
6
+ invitedBy : string,
7
+ invitedByName? : string,
8
+ link : string,
9
+ createdAt : string,
10
+ expiresAt : string,
11
+ emailed? : boolean,
12
+
13
+ };
14
+
15
+ type Inviter = {
16
+
17
+ id : string,
18
+ name? : string,
19
+
20
+ };
21
+
22
+ /* Invitations into this tenant. Membership itself is decided wherever people
23
+ sign in (Anbaric Cloud's central login), so a platform only asks that
24
+ service to invite, list and revoke on the tenant's behalf. */
25
+ interface MembershipService {
26
+
27
+ invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation>;
28
+
29
+ pending() : Promise<Array<PendingInvitation>>;
30
+
31
+ revoke(token : string) : Promise<boolean>;
32
+
33
+ }
34
+
35
+ export type { MembershipService, PendingInvitation, Inviter }
@@ -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,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 }
@@ -156,6 +156,16 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
156
156
  )
157
157
  `);
158
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
+ `);
159
169
  await pool.query(`
160
170
  DO $$ BEGIN
161
171
  CREATE TYPE anbaric_system.audit_interaction AS ENUM
@@ -13,7 +13,11 @@ 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";
17
+ import {InvitationsHandler} from "./handlers/auth/InvitationsHandler";
18
+ import {MembershipService} from "../auth/MembershipService";
16
19
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
20
+ import {UserDirectory} from "../auth/UserDirectory";
17
21
  import {ConsumersHandler} from "./handlers/ConsumersHandler";
18
22
  import {DocumentsHandler} from "./handlers/DocumentsHandler";
19
23
  import {EntitlementsAdminHandler} from "./handlers/EntitlementsAdminHandler";
@@ -62,7 +66,9 @@ class HostingServer {
62
66
  plugins : Array<LoadedPlugin> = [],
63
67
  jobRunSchedules? : JobRunSchedulePersistence,
64
68
  notifier? : Notifier,
65
- entitlements? : EntitlementStore) {
69
+ entitlements? : EntitlementStore,
70
+ userDirectory? : UserDirectory,
71
+ memberships? : MembershipService) {
66
72
  const pages = new PagesHandler();
67
73
  const ping = new PingHandler(tenant);
68
74
  const jobs = new JobsHandler(persistence);
@@ -92,6 +98,8 @@ class HostingServer {
92
98
  if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
93
99
  if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
94
100
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
101
+ if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
102
+ if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
95
103
  if (cliAuthorizer) {
96
104
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
97
105
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -128,7 +136,7 @@ class HostingServer {
128
136
 
129
137
  this.publicServer = new Server(publicRouter, [
130
138
  new SessionMiddleware(),
131
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
139
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
132
140
  ]);
133
141
  this.internalServer = new Server(internalRouter);
134
142
  }
@@ -0,0 +1,34 @@
1
+ import {MembershipService} from "../../../auth/MembershipService";
2
+ import {Request} from "../../Request";
3
+ import {RequestHandler} from "../../RequestHandler";
4
+
5
+ const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+
7
+ /* The console's side of inviting people into this tenant: list what is
8
+ pending, invite by email (attributed to the signed-in user), revoke. */
9
+ class InvitationsHandler implements RequestHandler {
10
+
11
+ constructor(private memberships : MembershipService) {}
12
+
13
+ async handle(request : Request) : Promise<void> {
14
+ if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending());
15
+ if (request.method === "POST" && ! request.id) return this.invite(request);
16
+ if (request.method === "DELETE" && request.id && ! request.subresource) {
17
+ const revoked = await this.memberships.revoke(request.id);
18
+ return revoked ? request.reply(204) : request.notFound();
19
+ }
20
+ request.notFound();
21
+ }
22
+
23
+ private async invite(request : Request) : Promise<void> {
24
+ const body = await request.body() as { email? : string } | undefined;
25
+ const email = String(body?.email ?? "").trim();
26
+ if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
27
+
28
+ const inviter = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
29
+ request.reply(201, await this.memberships.invite(email, inviter));
30
+ }
31
+
32
+ }
33
+
34
+ export { InvitationsHandler }
@@ -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
  }