anbaric-cloud-hosting 1.41.0 → 1.43.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.41.0",
3
+ "version": "1.43.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.41.0",
22
- "anbaric-plugins": "^1.41.0",
23
- "anbaric-tsapi": "^1.41.0",
24
- "anbaric-web": "^1.41.0",
21
+ "anbaric-data-store": "^1.43.0",
22
+ "anbaric-plugins": "^1.43.0",
23
+ "anbaric-tsapi": "^1.43.0",
24
+ "anbaric-web": "^1.43.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.41.0",
32
- "anbaric-state-machine": "^1.41.0",
31
+ "anbaric-impl-cloud": "^1.43.0",
32
+ "anbaric-state-machine": "^1.43.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -1,3 +1,5 @@
1
+ import {TenantRole} from "./TenantRole";
2
+
1
3
  class CliKey {
2
4
 
3
5
  readonly id : string;
@@ -5,16 +7,20 @@ class CliKey {
5
7
  readonly clientName : string;
6
8
  readonly publicKey : string;
7
9
  readonly tenant? : string;
10
+ // The role the key's owner held in that tenant when it was issued; the
11
+ // platform re-reads it on every lookup, so a demotion takes effect at once.
12
+ readonly tenantRole? : TenantRole;
8
13
  readonly createdAt : Date;
9
14
 
10
15
  constructor(id : string, userId : string, clientName : string, publicKey : string,
11
- tenant? : string, createdAt : Date = new Date()) {
16
+ tenant? : string, createdAt : Date = new Date(), tenantRole? : TenantRole) {
12
17
  this.id = id;
13
18
  this.userId = userId;
14
19
  this.clientName = clientName;
15
20
  this.publicKey = publicKey;
16
21
  this.tenant = tenant;
17
22
  this.createdAt = createdAt;
23
+ this.tenantRole = tenantRole;
18
24
  }
19
25
 
20
26
  }
@@ -1,5 +1,6 @@
1
1
  import {CliKey} from "./CliKey";
2
2
  import {CliKeyStore} from "./CliKeyStore";
3
+ import {TenantRole} from "./TenantRole";
3
4
 
4
5
  type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
5
6
 
@@ -44,8 +45,10 @@ class HttpCliKeyStore implements CliKeyStore {
44
45
  if (response.status === 404) return undefined;
45
46
  if (!response.ok) throw new Error(`The key lookup failed with status ${response.status}`);
46
47
 
47
- const found = await response.json() as { userId : string, clientName? : string, publicKey : string, tenant? : string };
48
- return new CliKey(id, found.userId, found.clientName ?? "", found.publicKey, found.tenant ?? undefined);
48
+ const found = await response.json() as
49
+ { userId : string, clientName? : string, publicKey : string, tenant? : string, tenantRole? : TenantRole };
50
+ return new CliKey(id, found.userId, found.clientName ?? "", found.publicKey, found.tenant ?? undefined,
51
+ new Date(), found.tenantRole);
49
52
  }
50
53
 
51
54
  }
@@ -1,40 +1,66 @@
1
1
  import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
2
+ import {isTenantRole, TenantRole} from "./TenantRole";
2
3
 
3
4
  type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
4
5
 
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. */
6
+ const ROLE_CACHE_TTL_MS = 60_000;
7
+
8
+ /* Talks to the central login about this one tenant, with the same central api
9
+ key the platform already holds for CLI key lookups. Roles are cached briefly
10
+ - every authenticated request asks for one, and a minute's staleness on a
11
+ role change is a fair trade for not calling central on each of them. */
7
12
  class HttpMembershipService implements MembershipService {
8
13
 
14
+ private roles = new Map<string, { role : TenantRole | undefined, expires : number }>();
15
+
9
16
  constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
10
- private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
17
+ private fetchFn : FetchFn = (url, init) => fetch(url, init),
18
+ private cacheTtlMs : number = ROLE_CACHE_TTL_MS) {}
19
+
20
+ async roleFor(userId : string) : Promise<TenantRole | undefined> {
21
+ const cached = this.roles.get(userId);
22
+ if (cached && cached.expires > Date.now()) return cached.role;
23
+
24
+ const response = await this.call("GET", `/members/${encodeURIComponent(userId)}`, undefined, [404]);
25
+ const role = response.status === 404
26
+ ? undefined
27
+ : (await response.json() as { role? : unknown }).role;
28
+ const found = isTenantRole(role) ? role : undefined;
29
+
30
+ this.roles.set(userId, { role: found, expires: Date.now() + this.cacheTtlMs });
31
+ return found;
32
+ }
11
33
 
12
- async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
13
- const response = await this.call("POST", "", { email, invitedBy });
34
+ async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
35
+ const response = await this.call("POST", "/invitations", { email, role, invitedBy });
14
36
  return await response.json() as PendingInvitation;
15
37
  }
16
38
 
17
- async pending() : Promise<Array<PendingInvitation>> {
18
- const response = await this.call("GET", "");
39
+ async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
40
+ const response = await this.call("GET", "/invitations", undefined, [], asking);
19
41
  return await response.json() as Array<PendingInvitation>;
20
42
  }
21
43
 
22
- async revoke(token : string) : Promise<boolean> {
23
- const response = await this.call("DELETE", `/${encodeURIComponent(token)}`, undefined, [404]);
44
+ async revoke(token : string, asking : Inviter) : Promise<boolean> {
45
+ const response = await this.call("DELETE", `/invitations/${encodeURIComponent(token)}`, undefined, [403, 404], asking);
24
46
  return response.status === 204;
25
47
  }
26
48
 
27
- private async call(method : string, path : string, body? : unknown, tolerated : Array<number> = []) : Promise<Response> {
49
+ private async call(method : string, path : string, body? : unknown,
50
+ tolerated : Array<number> = [], asking? : Inviter) : Promise<Response> {
28
51
  const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
29
52
  if (body !== undefined) headers["content-type"] = "application/json";
53
+ // Central checks the acting person's role, not just the api key, so who
54
+ // is asking travels on every call that is not already carrying them.
55
+ if (asking) headers["x-anbaric-user"] = asking.id;
30
56
 
31
57
  const response = await this.fetchFn(
32
- `${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/invitations${path}`,
58
+ `${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}${path}`,
33
59
  { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
34
60
 
35
61
  if (! response.ok && ! tolerated.includes(response.status)) {
36
62
  const problem = await response.json().catch(() => ({})) as { error? : string };
37
- throw new Error(problem.error ?? `The invitation request failed with status ${response.status}`);
63
+ throw new Error(problem.error ?? `The request to the central login failed with status ${response.status}`);
38
64
  }
39
65
  return response;
40
66
  }
@@ -1,19 +1,29 @@
1
1
  import {randomUUID} from "node:crypto";
2
2
  import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
3
+ import {canInvite, TenantRole} from "./TenantRole";
3
4
 
4
5
  class InMemoryMembershipService implements MembershipService {
5
6
 
6
7
  private invitations = new Map<string, PendingInvitation>();
8
+ private members = new Map<string, TenantRole>();
7
9
 
8
10
  constructor(private linkBase : string = "http://localhost/invitations") {}
9
11
 
10
- async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
12
+ add(userId : string, role : TenantRole) : void {
13
+ this.members.set(userId, role);
14
+ }
15
+
16
+ async roleFor(userId : string) : Promise<TenantRole | undefined> {
17
+ return this.members.get(userId);
18
+ }
19
+
20
+ async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
11
21
  const token = randomUUID();
12
22
  const now = new Date();
13
23
  const invitation : PendingInvitation = {
14
24
  token,
15
25
  email: email.trim().toLowerCase(),
16
- role: "member",
26
+ role,
17
27
  invitedBy: invitedBy.id,
18
28
  invitedByName: invitedBy.name,
19
29
  link: `${this.linkBase}/${token}`,
@@ -25,11 +35,13 @@ class InMemoryMembershipService implements MembershipService {
25
35
  return { ...invitation };
26
36
  }
27
37
 
28
- async pending() : Promise<Array<PendingInvitation>> {
38
+ async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
39
+ if (! canInvite(this.members.get(asking.id))) return [];
29
40
  return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
30
41
  }
31
42
 
32
- async revoke(token : string) : Promise<boolean> {
43
+ async revoke(token : string, asking : Inviter) : Promise<boolean> {
44
+ if (! canInvite(this.members.get(asking.id))) return false;
33
45
  return this.invitations.delete(token);
34
46
  }
35
47
 
@@ -1,8 +1,10 @@
1
+ import {TenantRole} from "./TenantRole";
2
+
1
3
  type PendingInvitation = {
2
4
 
3
5
  token : string,
4
6
  email : string,
5
- role : string,
7
+ role : TenantRole,
6
8
  invitedBy : string,
7
9
  invitedByName? : string,
8
10
  link : string,
@@ -19,16 +21,19 @@ type Inviter = {
19
21
 
20
22
  };
21
23
 
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. */
24
+ /* Who belongs to this tenant and as what, and the invitations that get them
25
+ here. Membership is decided wherever people sign in (Anbaric Cloud's central
26
+ login), which owns the tenant definitions; a platform asks it who its caller
27
+ is, and asks it to invite, list and revoke on the tenant's behalf. */
25
28
  interface MembershipService {
26
29
 
27
- invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation>;
30
+ roleFor(userId : string) : Promise<TenantRole | undefined>;
31
+
32
+ invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation>;
28
33
 
29
- pending() : Promise<Array<PendingInvitation>>;
34
+ pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
30
35
 
31
- revoke(token : string) : Promise<boolean>;
36
+ revoke(token : string, asking : Inviter) : Promise<boolean>;
32
37
 
33
38
  }
34
39
 
@@ -3,6 +3,7 @@ import {ServerResponse} from "node:http";
3
3
  import {SESSION_COOKIE} from "./Authenticator";
4
4
  import {Role} from "./Role";
5
5
  import {Tenant} from "./Tenant";
6
+ import {TenantRole, isTenantRole} from "./TenantRole";
6
7
  import {User} from "./User";
7
8
 
8
9
  const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
@@ -43,6 +44,7 @@ class SessionSigner {
43
44
  name: user.name,
44
45
  picture: user.picture,
45
46
  email: user.email,
47
+ tenantRole: user.tenantRole,
46
48
  exp: nowSeconds() + this.ttlSeconds,
47
49
  };
48
50
  const payload = base64url(JSON.stringify(claims));
@@ -55,7 +57,7 @@ class SessionSigner {
55
57
  const [payload, signature] = token.split(".");
56
58
  if (!payload || !signature || !this.signatureMatches(payload, signature)) return undefined;
57
59
 
58
- let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, exp? : unknown };
60
+ let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, tenantRole? : unknown, exp? : unknown };
59
61
  try {
60
62
  claims = JSON.parse(Buffer.from(payload, "base64url").toString());
61
63
  } catch {
@@ -70,7 +72,8 @@ class SessionSigner {
70
72
  const name = typeof claims.name === "string" ? claims.name : undefined;
71
73
  const picture = typeof claims.picture === "string" ? claims.picture : undefined;
72
74
  const email = typeof claims.email === "string" ? claims.email : undefined;
73
- return [new User(claims.sub, roles, [], name, picture, email), tenant];
75
+ const tenantRole = isTenantRole(claims.tenantRole) ? claims.tenantRole as TenantRole : undefined;
76
+ return [new User(claims.sub, roles, [], name, picture, email, tenantRole), tenant];
74
77
  }
75
78
 
76
79
  // Sets (or, for a still-valid session, refreshes) the session cookie.
@@ -0,0 +1,28 @@
1
+ /* What a person may do in the tenant this platform serves. The roles are
2
+ assigned in Anbaric Cloud's central login, which owns the tenant definitions
3
+ and the user-to-tenant mapping; the platform only enforces them. */
4
+ type TenantRole = "OWNER" | "ADMIN" | "BUILDER" | "USER";
5
+
6
+ const TENANT_ROLES : Array<TenantRole> = ["OWNER", "ADMIN", "BUILDER", "USER"];
7
+
8
+ const isTenantRole = (value : unknown) : value is TenantRole =>
9
+ typeof value === "string" && TENANT_ROLES.includes(value as TenantRole);
10
+
11
+ /* Inviting and revoking members - administering who else is in the tenant. */
12
+ const canInvite = (role : TenantRole | undefined) : boolean =>
13
+ role === "OWNER" || role === "ADMIN";
14
+
15
+ /* Deploying and tearing down apps, and minting the CLI keypairs that do it.
16
+ A USER can use the tenant's console and its apps, but cannot change them. */
17
+ const canBuild = (role : TenantRole | undefined) : boolean =>
18
+ role === "OWNER" || role === "ADMIN" || role === "BUILDER";
19
+
20
+ /* A platform with no membership service in play - a local or unmanaged
21
+ install - resolves no roles at all, and there every caller is allowed, as
22
+ they were before roles existed. Where one is in play the authentication
23
+ middleware has already refused anyone with no membership, so an undefined
24
+ role at a handler means only that roles are not being enforced here. */
25
+ const deniesBuild = (role : TenantRole | undefined) : boolean => role !== undefined && ! canBuild(role);
26
+
27
+ export { TENANT_ROLES, isTenantRole, canInvite, canBuild, deniesBuild };
28
+ export type { TenantRole };
@@ -37,7 +37,7 @@ class TokenAuthenticator {
37
37
  if (this.tenant && key.tenant && key.tenant !== this.tenant) return undefined;
38
38
  if (!this.signatureValid(headerPart, payloadPart, signaturePart, key.publicKey)) return undefined;
39
39
 
40
- return new User(key.userId);
40
+ return new User(key.userId, [], [], undefined, undefined, undefined, key.tenantRole);
41
41
  }
42
42
 
43
43
  private signatureValid(headerPart : string, payloadPart : string, signaturePart : string,
package/src/auth/User.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {KeyPair} from "./KeyPair";
2
2
  import {Role} from "./Role";
3
+ import {TenantRole} from "./TenantRole";
3
4
 
4
5
  class User {
5
6
 
@@ -9,15 +10,20 @@ class User {
9
10
  readonly name? : string;
10
11
  readonly picture? : string;
11
12
  readonly email? : string;
13
+ // What this person may do in the tenant they are signed in to. Kept apart
14
+ // from `roles`, which are the identity provider's and are what an app's own
15
+ // authorization matches on - a platform capability is not a domain role.
16
+ tenantRole? : TenantRole;
12
17
 
13
18
  constructor(id : string, roles : Array<Role> = [], keyPairs : Array<KeyPair> = [],
14
- name? : string, picture? : string, email? : string) {
19
+ name? : string, picture? : string, email? : string, tenantRole? : TenantRole) {
15
20
  this.id = id;
16
21
  this.roles = roles;
17
22
  this.keyPairs = keyPairs;
18
23
  this.name = name;
19
24
  this.picture = picture;
20
25
  this.email = email;
26
+ this.tenantRole = tenantRole;
21
27
  }
22
28
 
23
29
  hasRole(role : Role) : boolean {
@@ -141,7 +141,7 @@ class HostingServer {
141
141
 
142
142
  this.publicServer = new Server(publicRouter, [
143
143
  new SessionMiddleware(),
144
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
144
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships),
145
145
  ]);
146
146
  this.internalServer = new Server(internalRouter);
147
147
  }
@@ -1,4 +1,5 @@
1
1
  import {BuildLayer} from "../../app-management/BuildLayer";
2
+ import {deniesBuild} from "../../auth/TenantRole";
2
3
  import {Request} from "../Request";
3
4
  import {RequestHandler} from "../RequestHandler";
4
5
 
@@ -7,6 +8,11 @@ class AppsHandler implements RequestHandler {
7
8
  constructor(private buildLayer : BuildLayer) {}
8
9
 
9
10
  async handle(request : Request) : Promise<void> {
11
+ // Changing what is deployed is a builder's job; a USER may look.
12
+ if (this.changesApps(request) && deniesBuild(request.user?.tenantRole)) {
13
+ return request.reply(403, { error: "Your role in this tenant cannot deploy or remove apps" });
14
+ }
15
+
10
16
  await this.buildLayer.ensureHydrated();
11
17
  switch (request.subresource) {
12
18
  case "deploy":
@@ -25,6 +31,11 @@ class AppsHandler implements RequestHandler {
25
31
  request.notFound();
26
32
  }
27
33
 
34
+ private changesApps(request : Request) : boolean {
35
+ if (request.subresource === "deploy" || request.subresource === "docs") return true;
36
+ return request.subresource === undefined && request.method === "DELETE";
37
+ }
38
+
28
39
  private async handleDocs(request : Request, appName : string) : Promise<void> {
29
40
  if (request.method !== "POST") return request.notFound();
30
41
  try {
@@ -1,5 +1,6 @@
1
1
  import {CliAuthorizer} from "../../../auth/CliAuthorizer";
2
2
  import {User} from "../../../auth/User";
3
+ import {deniesBuild} from "../../../auth/TenantRole";
3
4
  import {Request} from "../../Request";
4
5
  import {RequestHandler} from "../../RequestHandler";
5
6
  import {PagesHandler} from "../PagesHandler";
@@ -40,8 +41,15 @@ class AuthorizeCliHandler implements RequestHandler {
40
41
  if (typeof clientName !== "string" || clientName.trim().length === 0) {
41
42
  return request.reply(400, { error: "Expected a body of { clientName : string }" });
42
43
  }
44
+ // A key deploys as its owner, so only someone who may deploy may mint one.
45
+ if (deniesBuild(request.user?.tenantRole)) {
46
+ return request.reply(403, { error: "Your role in this tenant cannot create keys" });
47
+ }
48
+
49
+ // The tenant is this platform's own: what it is deployed as, which is
50
+ // the slug the edge routes on and the CLI is later checked against.
43
51
  await this.authorizer.approve(requestId, clientName.trim(), request.user ?? new User("local"),
44
- request.tenant?.id ?? this.tenant);
52
+ this.tenant ?? request.tenant?.id);
45
53
  request.reply(204);
46
54
  }
47
55
 
@@ -1,4 +1,5 @@
1
1
  import {MembershipService} from "../../../auth/MembershipService";
2
+ import {canInvite, isTenantRole} from "../../../auth/TenantRole";
2
3
  import {Request} from "../../Request";
3
4
  import {RequestHandler} from "../../RequestHandler";
4
5
 
@@ -11,22 +12,38 @@ class InvitationsHandler implements RequestHandler {
11
12
  constructor(private memberships : MembershipService) {}
12
13
 
13
14
  async handle(request : Request) : Promise<void> {
14
- if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending());
15
+ // Only a tenant's owner or admin administers its membership. Central
16
+ // checks this too - it is the authority - but refusing here keeps the
17
+ // console honest and gives a clear answer without a round trip.
18
+ if (! canInvite(request.user?.tenantRole)) {
19
+ return request.reply(403, { error: "Your role in this tenant cannot manage members" });
20
+ }
21
+
22
+ const asking = this.asking(request);
23
+ if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending(asking));
15
24
  if (request.method === "POST" && ! request.id) return this.invite(request);
16
25
  if (request.method === "DELETE" && request.id && ! request.subresource) {
17
- const revoked = await this.memberships.revoke(request.id);
26
+ const revoked = await this.memberships.revoke(request.id, asking);
18
27
  return revoked ? request.reply(204) : request.notFound();
19
28
  }
20
29
  request.notFound();
21
30
  }
22
31
 
32
+ private asking(request : Request) {
33
+ return request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
34
+ }
35
+
23
36
  private async invite(request : Request) : Promise<void> {
24
- const body = await request.body() as { email? : string } | undefined;
37
+ const body = await request.body() as { email? : string, role? : string } | undefined;
25
38
  const email = String(body?.email ?? "").trim();
26
39
  if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
27
40
 
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));
41
+ const role = body?.role ?? "USER";
42
+ if (! isTenantRole(role) || role === "OWNER") {
43
+ return request.reply(400, { error: "Invite someone as an ADMIN, BUILDER or USER" });
44
+ }
45
+
46
+ request.reply(201, await this.memberships.invite(email, role, this.asking(request)));
30
47
  }
31
48
 
32
49
  }
@@ -14,6 +14,7 @@ class WhoamiHandler implements RequestHandler {
14
14
  roles: request.user.roles.map(role => role.id),
15
15
  name: request.user.name,
16
16
  picture: request.user.picture,
17
+ tenantRole: request.user.tenantRole,
17
18
  tenant: this.tenant ?? request.tenant?.id,
18
19
  });
19
20
  }
@@ -3,6 +3,7 @@ import {SessionSigner} from "../../auth/SessionSigner";
3
3
  import {TokenAuthenticator} from "../../auth/TokenAuthenticator";
4
4
  import {User} from "../../auth/User";
5
5
  import {UserDirectory} from "../../auth/UserDirectory";
6
+ import {MembershipService} from "../../auth/MembershipService";
6
7
  import {Middleware} from "../Middleware";
7
8
  import {Request} from "../Request";
8
9
 
@@ -26,7 +27,8 @@ class AuthenticationMiddleware implements Middleware {
26
27
  private tokenAuthenticator? : TokenAuthenticator,
27
28
  private isOpen : OpenRequestPredicate = () => false,
28
29
  private sessionSigner : SessionSigner = new SessionSigner(),
29
- private userDirectory? : UserDirectory) {}
30
+ private userDirectory? : UserDirectory,
31
+ private memberships? : MembershipService) {}
30
32
 
31
33
  async apply(request : Request) : Promise<boolean> {
32
34
  if (this.isOpen(request)) return true;
@@ -35,6 +37,7 @@ class AuthenticationMiddleware implements Middleware {
35
37
  const user = await this.tokenAuthenticator.authenticate(request.raw, request.rawResponse);
36
38
  if (!user) return false;
37
39
  request.user = user;
40
+ if (! await this.admitted(request)) return false;
38
41
  return this.authorized(request);
39
42
  }
40
43
 
@@ -42,6 +45,7 @@ class AuthenticationMiddleware implements Middleware {
42
45
  const session = this.sessionSigner.verify(request.session);
43
46
  if (session) {
44
47
  [request.user, request.tenant] = session;
48
+ if (! await this.admitted(request)) return false;
45
49
  this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
46
50
  await this.remember(request.user!);
47
51
  return this.authorized(request);
@@ -63,6 +67,7 @@ class AuthenticationMiddleware implements Middleware {
63
67
  if (!authenticated) return false;
64
68
 
65
69
  [request.user, request.tenant] = authenticated;
70
+ if (! await this.admitted(request)) return false;
66
71
  if (this.sessionSigner.configured && !request.handled) {
67
72
  this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
68
73
  }
@@ -70,6 +75,26 @@ class AuthenticationMiddleware implements Middleware {
70
75
  return this.authorized(request);
71
76
  }
72
77
 
78
+ /* Membership in this tenant is what admits a browser session at all, and
79
+ the role it carries is what the handlers gate on. A platform with no
80
+ membership service - a local or unmanaged install - admits everyone, as
81
+ it always has. Central being unreachable must not lock the tenant out,
82
+ so a lookup that throws is left to the handlers to refuse. */
83
+ private async admitted(request : Request) : Promise<boolean> {
84
+ if (! this.memberships || ! request.user) return true;
85
+
86
+ try {
87
+ request.user.tenantRole = await this.memberships.roleFor(request.user.id);
88
+ } catch (error) {
89
+ console.error(`[membership] could not resolve a role for "${request.user.id}":`, error);
90
+ return true;
91
+ }
92
+
93
+ if (request.user.tenantRole) return true;
94
+ if (! request.handled) request.reply(403, { error: "You are not a member of this tenant" });
95
+ return false;
96
+ }
97
+
73
98
  private async remember(user : User) : Promise<void> {
74
99
  if (! this.userDirectory) return;
75
100