anbaric-cloud-hosting 1.1.1 → 1.2.1

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.1.1",
3
+ "version": "1.2.1",
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.1.1",
21
- "anbaric-tsapi": "^1.1.1",
20
+ "anbaric-data-store": "^1.2.1",
21
+ "anbaric-tsapi": "^1.2.1",
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.1.1",
28
- "anbaric-state-machine": "^1.1.1",
27
+ "anbaric-cloud": "^1.2.1",
28
+ "anbaric-state-machine": "^1.2.1",
29
29
  "tsx": "^4.20.0",
30
30
  "typescript": "^7.0.2"
31
31
  },
@@ -1,7 +1,9 @@
1
1
  import {Authenticator} from "./Authenticator";
2
+ import {StubAuthenticator} from "./StubAuthenticator";
2
3
 
3
4
  const loadAuthenticator = async (moduleName? : string) : Promise<Authenticator | undefined> => {
4
5
  if (!moduleName) return undefined;
6
+ if (moduleName === "stub") return new StubAuthenticator();
5
7
 
6
8
  const module = await import(moduleName);
7
9
  if (typeof module.createAuthenticator !== "function") {
@@ -22,7 +22,7 @@ class CliAuthorizer {
22
22
  const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
23
23
  const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
24
24
 
25
- const key = new CliKey(randomUUID(), user.id, clientName, publicKeyPem);
25
+ const key = new CliKey(randomUUID(), user.id, clientName, publicKeyPem, tenant);
26
26
  await this.keyStore.save(key);
27
27
 
28
28
  this.issued.set(requestId, {
@@ -4,14 +4,16 @@ class CliKey {
4
4
  readonly userId : string;
5
5
  readonly clientName : string;
6
6
  readonly publicKey : string;
7
+ readonly tenant? : string;
7
8
  readonly createdAt : Date;
8
9
 
9
10
  constructor(id : string, userId : string, clientName : string, publicKey : string,
10
- createdAt : Date = new Date()) {
11
+ tenant? : string, createdAt : Date = new Date()) {
11
12
  this.id = id;
12
13
  this.userId = userId;
13
14
  this.clientName = clientName;
14
15
  this.publicKey = publicKey;
16
+ this.tenant = tenant;
15
17
  this.createdAt = createdAt;
16
18
  }
17
19
 
@@ -0,0 +1,53 @@
1
+ import {CliKey} from "./CliKey";
2
+ import {CliKeyStore} from "./CliKeyStore";
3
+
4
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
5
+
6
+ const CACHE_TTL_MS = 60_000;
7
+
8
+ /* Looks CLI keys up from a central key registry over HTTP - used when a
9
+ platform's keys are issued elsewhere (Anbaric Cloud's central login).
10
+ Read-only: key issuance, listing and revocation live with the registry. */
11
+ class HttpCliKeyStore implements CliKeyStore {
12
+
13
+ private cache = new Map<string, { key : CliKey | undefined, expires : number }>();
14
+
15
+ constructor(private lookupUrl : string, private secret : string,
16
+ private fetchFn : FetchFn = (url, init) => fetch(url, init),
17
+ private cacheTtlMs : number = CACHE_TTL_MS) {}
18
+
19
+ async find(id : string) : Promise<CliKey | undefined> {
20
+ const cached = this.cache.get(id);
21
+ if (cached && cached.expires > Date.now()) return cached.key;
22
+
23
+ const key = await this.lookup(id);
24
+ this.cache.set(id, { key, expires: Date.now() + this.cacheTtlMs });
25
+ return key;
26
+ }
27
+
28
+ async save(_key : CliKey) : Promise<void> {
29
+ throw new Error("Keys are issued by the central registry, not by this platform");
30
+ }
31
+
32
+ async listFor(_userId : string) : Promise<Array<CliKey>> {
33
+ return [];
34
+ }
35
+
36
+ async delete(_id : string, _userId : string) : Promise<void> {
37
+ throw new Error("Keys are revoked at the central registry, not by this platform");
38
+ }
39
+
40
+ private async lookup(id : string) : Promise<CliKey | undefined> {
41
+ const response = await this.fetchFn(`${this.lookupUrl}/cli-keys/${encodeURIComponent(id)}`, {
42
+ headers: { "x-anbaric-central-key": this.secret },
43
+ });
44
+ if (response.status === 404) return undefined;
45
+ if (!response.ok) throw new Error(`The key lookup failed with status ${response.status}`);
46
+
47
+ const found = await response.json();
48
+ return new CliKey(id, found.userId, found.clientName ?? "", found.publicKey, found.tenant ?? undefined);
49
+ }
50
+
51
+ }
52
+
53
+ export { HttpCliKeyStore }
@@ -0,0 +1,24 @@
1
+ import {IncomingMessage, ServerResponse} from "node:http";
2
+ import {Authenticator} from "./Authenticator";
3
+ import {Tenant} from "./Tenant";
4
+ import {User} from "./User";
5
+
6
+ /* A development authenticator: every request is authenticated as a fixed
7
+ user without any credential check, so the auth-dependent surface (whoami,
8
+ keys, the dashboard) can be exercised with no identity provider. Never use
9
+ it on a platform reachable by anyone you would not trust as that user. */
10
+ class StubAuthenticator extends Authenticator {
11
+
12
+ constructor(private userId : string = process.env.ANBARIC_STUB_USER ?? "local-admin",
13
+ private tenantId : string = process.env.ANBARIC_TENANT ?? "local") {
14
+ super();
15
+ }
16
+
17
+ async authenticate(_session : string | undefined, _request : IncomingMessage,
18
+ _response : ServerResponse) : Promise<[User, Tenant] | undefined> {
19
+ return [new User(this.userId), new Tenant(this.tenantId)];
20
+ }
21
+
22
+ }
23
+
24
+ export { StubAuthenticator }
@@ -7,7 +7,7 @@ const BEARER_PREFIX = "Bearer ";
7
7
 
8
8
  class TokenAuthenticator {
9
9
 
10
- constructor(private keyStore : CliKeyStore) {}
10
+ constructor(private keyStore : CliKeyStore, private tenant? : string) {}
11
11
 
12
12
  handles(request : IncomingMessage) : boolean {
13
13
  return String(request.headers.authorization ?? "").startsWith(BEARER_PREFIX);
@@ -34,6 +34,7 @@ class TokenAuthenticator {
34
34
 
35
35
  const key = await this.keyStore.find(header.kid);
36
36
  if (!key) return undefined;
37
+ if (this.tenant && key.tenant && key.tenant !== this.tenant) return undefined;
37
38
  if (!this.signatureValid(headerPart, payloadPart, signaturePart, key.publicKey)) return undefined;
38
39
 
39
40
  return new User(key.userId);
@@ -8,28 +8,28 @@ class PostgresCliKeyStore implements CliKeyStore {
8
8
 
9
9
  async save(key : CliKey) : Promise<void> {
10
10
  await this.pool.query(
11
- "INSERT INTO anbaric_system.cli_keys (id, user_id, client_name, public_key) VALUES ($1, $2, $3, $4)",
12
- [key.id, key.userId, key.clientName, key.publicKey],
11
+ "INSERT INTO anbaric_system.cli_keys (id, user_id, client_name, public_key, tenant) VALUES ($1, $2, $3, $4, $5)",
12
+ [key.id, key.userId, key.clientName, key.publicKey, key.tenant ?? null],
13
13
  );
14
14
  }
15
15
 
16
16
  async find(id : string) : Promise<CliKey | undefined> {
17
17
  const result = await this.pool.query(
18
- "SELECT id, user_id, client_name, public_key, created_at FROM anbaric_system.cli_keys WHERE id = $1",
18
+ "SELECT id, user_id, client_name, public_key, tenant, created_at FROM anbaric_system.cli_keys WHERE id = $1",
19
19
  [id],
20
20
  );
21
21
  const row = result.rows[0];
22
- return row && new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.created_at);
22
+ return row && new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.tenant ?? undefined, row.created_at);
23
23
  }
24
24
 
25
25
  async listFor(userId : string) : Promise<Array<CliKey>> {
26
26
  const result = await this.pool.query(
27
- "SELECT id, user_id, client_name, public_key, created_at FROM anbaric_system.cli_keys WHERE user_id = $1 ORDER BY created_at",
27
+ "SELECT id, user_id, client_name, public_key, tenant, created_at FROM anbaric_system.cli_keys WHERE user_id = $1 ORDER BY created_at",
28
28
  [userId],
29
29
  );
30
30
 
31
31
  return result.rows.map(row =>
32
- new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.created_at));
32
+ new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.tenant ?? undefined, row.created_at));
33
33
  }
34
34
 
35
35
  async delete(id : string, userId : string) : Promise<void> {
@@ -47,9 +47,11 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
47
47
  user_id TEXT NOT NULL,
48
48
  client_name TEXT NOT NULL,
49
49
  public_key TEXT NOT NULL,
50
+ tenant TEXT,
50
51
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
51
52
  )
52
53
  `);
54
+ await pool.query("ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT");
53
55
  };
54
56
 
55
57
  export { ensureSchema }
@@ -27,7 +27,7 @@ class HostingServer {
27
27
  private authenticator? : Authenticator,
28
28
  cliAuthorizer? : CliAuthorizer,
29
29
  private tokenAuthenticator? : TokenAuthenticator,
30
- tenant? : string) {
30
+ private tenant? : string) {
31
31
  this.router = new Router(persistence, queue, registry, buildLayer, documentStoreFor, secretStore, cliAuthorizer, tenant);
32
32
  this.server = this.serverFor((request, response) => this.handle(request, response));
33
33
  this.internalServer = this.serverFor((request, response) => this.handleInternal(request, response));
@@ -63,7 +63,7 @@ class HostingServer {
63
63
  private async handle(request : IncomingMessage, response : ServerResponse) : Promise<void> {
64
64
  const path = request.url?.split("?")[0] ?? "/";
65
65
  if (path === "/ping" && request.method === "GET") {
66
- return this.reply(response, 200, { status: "ok" });
66
+ return this.reply(response, 200, this.tenant ? { status: "ok", tenant: this.tenant } : { status: "ok" });
67
67
  }
68
68
  if (/^\/authorize-cli\/[^/]+\/poll$/.test(path) && request.method === "GET") {
69
69
  return this.router.route(request, response);
@@ -85,7 +85,7 @@ class HostingServer {
85
85
  private async handleInternal(request : IncomingMessage, response : ServerResponse) : Promise<void> {
86
86
  const path = request.url?.split("?")[0] ?? "/";
87
87
  if (path === "/ping" && request.method === "GET") {
88
- return this.reply(response, 200, { status: "ok" });
88
+ return this.reply(response, 200, this.tenant ? { status: "ok", tenant: this.tenant } : { status: "ok" });
89
89
  }
90
90
  const [resource] = path.split("/").filter(Boolean);
91
91
  if (!resource || !INTERNAL_RESOURCES.has(resource)) {
package/src/index.ts CHANGED
@@ -3,10 +3,12 @@ export * from "./auth/Authenticator";
3
3
  export * from "./auth/CliAuthorizer";
4
4
  export * from "./auth/CliKey";
5
5
  export * from "./auth/CliKeyStore";
6
+ export * from "./auth/HttpCliKeyStore";
6
7
  export * from "./auth/InMemoryCliKeyStore";
7
8
  export * from "./auth/AuthenticatorLoader";
8
9
  export * from "./auth/KeyPair";
9
10
  export * from "./auth/Role";
11
+ export * from "./auth/StubAuthenticator";
10
12
  export * from "./auth/Tenant";
11
13
  export * from "./auth/TokenAuthenticator";
12
14
  export * from "./auth/User";
package/src/main.ts CHANGED
@@ -4,6 +4,7 @@ import {InMemorySecretStore} from "anbaric-data-store";
4
4
  import {Pool} from "pg";
5
5
  import {loadAuthenticator} from "./auth/AuthenticatorLoader";
6
6
  import {CliAuthorizer} from "./auth/CliAuthorizer";
7
+ import {HttpCliKeyStore} from "./auth/HttpCliKeyStore";
7
8
  import {TokenAuthenticator} from "./auth/TokenAuthenticator";
8
9
  import {PostgresCliKeyStore} from "./data-store/PostgresCliKeyStore";
9
10
  import {ensureSchema} from "./data-store/Schema";
@@ -56,9 +57,11 @@ const secretStore : SecretStore = process.env.AWS_REGION
56
57
  : new InMemorySecretStore();
57
58
 
58
59
  const authenticator = await loadAuthenticator(process.env.ANBARIC_AUTHENTICATOR);
59
- const cliKeyStore = new PostgresCliKeyStore(pool);
60
+ const cliKeyStore = process.env.ANBARIC_CLI_KEY_LOOKUP_URL
61
+ ? new HttpCliKeyStore(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "")
62
+ : new PostgresCliKeyStore(pool);
60
63
  const cliAuthorizer = new CliAuthorizer(cliKeyStore);
61
- const tokenAuthenticator = new TokenAuthenticator(cliKeyStore);
64
+ const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBARIC_TENANT);
62
65
 
63
66
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
64
67
  (collection) => new PostgresJsonStore(pool, collection), secretStore, authenticator, cliAuthorizer,