anbaric-cloud-hosting 1.62.0 → 1.64.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.62.0",
3
+ "version": "1.64.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.62.0",
22
- "anbaric-plugins": "^1.62.0",
23
- "anbaric-tsapi": "^1.62.0",
24
- "anbaric-web": "^1.62.0",
21
+ "anbaric-data-store": "^1.64.0",
22
+ "anbaric-plugins": "^1.64.0",
23
+ "anbaric-tsapi": "^1.64.0",
24
+ "anbaric-web": "^1.64.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.62.0",
32
- "anbaric-state-machine": "^1.62.0",
31
+ "anbaric-impl-cloud": "^1.64.0",
32
+ "anbaric-state-machine": "^1.64.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -4,6 +4,15 @@ import {User} from "./User";
4
4
 
5
5
  const SESSION_COOKIE = "anbaric_session";
6
6
 
7
+ /* The identity cookie: who this person is, as the central login proved it,
8
+ set for the whole domain and never overwritten by a tenant platform. A
9
+ platform's own session cookie is bound to that platform - signed with its
10
+ secret - so moving between tenants invalidates it every time; this one is
11
+ bound to the person, and is what a platform falls back to before sending
12
+ anyone through the login again. Mirrors CENTRAL_SESSION_COOKIE in
13
+ anbaric-cloud-central; keep the two in step. */
14
+ const IDENTITY_COOKIE = "anbaric_central_session";
15
+
7
16
  abstract class Authenticator {
8
17
 
9
18
  abstract authenticate(session : string | undefined, request : IncomingMessage,
@@ -30,4 +39,4 @@ abstract class Authenticator {
30
39
 
31
40
  }
32
41
 
33
- export { Authenticator, SESSION_COOKIE }
42
+ export { Authenticator, SESSION_COOKIE, IDENTITY_COOKIE }
@@ -46,6 +46,12 @@ class HttpMembershipService implements MembershipService {
46
46
  return response.status === 204;
47
47
  }
48
48
 
49
+ async remove(userId : string, asking : Inviter) : Promise<boolean> {
50
+ const response = await this.call("DELETE", `/members/${encodeURIComponent(userId)}`, undefined, [404], asking);
51
+ this.roles.delete(userId);
52
+ return response.status === 204;
53
+ }
54
+
49
55
  private async call(method : string, path : string, body? : unknown,
50
56
  tolerated : Array<number> = [], asking? : Inviter) : Promise<Response> {
51
57
  const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
@@ -17,6 +17,10 @@ class InMemoryMembershipService implements MembershipService {
17
17
  return this.members.get(userId);
18
18
  }
19
19
 
20
+ async remove(userId : string, _asking : Inviter) : Promise<boolean> {
21
+ return this.members.delete(userId);
22
+ }
23
+
20
24
  async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
21
25
  const token = randomUUID();
22
26
  const now = new Date();
@@ -39,6 +39,9 @@ interface MembershipService {
39
39
 
40
40
  revoke(token : string, asking : Inviter) : Promise<boolean>;
41
41
 
42
+ // Ends someone's membership. False when they were not a member.
43
+ remove(userId : string, asking : Inviter) : Promise<boolean>;
44
+
42
45
  }
43
46
 
44
47
  export type { MembershipService, PendingInvitation, Inviter }
@@ -10,6 +10,7 @@ import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
10
  import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
11
11
  import {AppsHandler} from "./handlers/AppsHandler";
12
12
  import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
+ import {MembersHandler} from "./handlers/auth/MembersHandler";
13
14
  import {Subdomains} from "../subdomains/Subdomains";
14
15
  import {AuditsHandler} from "./handlers/AuditsHandler";
15
16
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -111,6 +112,7 @@ class HostingServer {
111
112
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
112
113
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
113
114
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
115
+ if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
114
116
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
115
117
  if (prompts) publicRouter.registerApi("prompts", prompts);
116
118
  if (cliAuthorizer) {
@@ -12,6 +12,8 @@ class Request {
12
12
 
13
13
  readonly rayId : string;
14
14
  session? : string;
15
+ // The identity cookie from the central login, when the browser holds one.
16
+ identity? : string;
15
17
  user? : User;
16
18
  tenant? : Tenant;
17
19
 
@@ -0,0 +1,43 @@
1
+ import {MembershipService} from "../../../auth/MembershipService";
2
+ import {canInvite} from "../../../auth/TenantRole";
3
+ import {EntitlementStore} from "../../../data-store/EntitlementStore";
4
+ import {Request} from "../../Request";
5
+ import {RequestHandler} from "../../RequestHandler";
6
+
7
+ /* The console's side of removing someone from this tenant. Membership is the
8
+ control plane's to end - it is what admits a session at all - and it is
9
+ asked to; what is this platform's own is the entitlements that person held
10
+ here, which go with them so nothing is left granted to somebody who can no
11
+ longer sign in. Their directory entry stays: it is a record of who was here. */
12
+ class MembersHandler implements RequestHandler {
13
+
14
+ constructor(private memberships : MembershipService, private entitlements? : EntitlementStore) {}
15
+
16
+ async handle(request : Request) : Promise<void> {
17
+ if (request.method !== "DELETE" || ! request.id || request.subresource) return request.notFound();
18
+
19
+ if (! canInvite(request.user?.tenantRole)) {
20
+ return request.reply(403, { error: "Your role in this tenant cannot remove members" });
21
+ }
22
+ if (request.user?.id === request.id) {
23
+ return request.reply(400, { error: "You cannot remove yourself from a tenant" });
24
+ }
25
+
26
+ const asking = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
27
+ let removed : boolean;
28
+ try {
29
+ removed = await this.memberships.remove(request.id, asking);
30
+ } catch (error) {
31
+ return request.reply(403, { error: error instanceof Error ? error.message : "Could not remove that member" });
32
+ }
33
+ if (! removed) return request.notFound();
34
+
35
+ for (const grant of await this.entitlements?.listGrants(request.id) ?? []) {
36
+ await this.entitlements?.revoke(grant.id);
37
+ }
38
+ request.reply(204);
39
+ }
40
+
41
+ }
42
+
43
+ export { MembersHandler }
@@ -71,7 +71,12 @@ class AuthenticationMiddleware implements Middleware {
71
71
 
72
72
  if (!this.authenticator) return true;
73
73
 
74
- const authenticated = await this.authenticator.authenticate(request.session, request.raw, request.rawResponse);
74
+ /* No session this platform can verify. Before sending anyone back
75
+ through the login, offer the authenticator the identity cookie the
76
+ central login set for the whole domain: a person arriving from
77
+ another tenant carries that tenant's session, which this platform
78
+ cannot read, but their identity is the same and still proven. */
79
+ const authenticated = await this.authenticator.authenticate(request.identity ?? request.session, request.raw, request.rawResponse);
75
80
  if (!authenticated) return false;
76
81
 
77
82
  [request.user, request.tenant] = authenticated;
@@ -1,17 +1,18 @@
1
- import {SESSION_COOKIE} from "../../auth/Authenticator";
1
+ import {IDENTITY_COOKIE, SESSION_COOKIE} from "../../auth/Authenticator";
2
2
  import {Middleware} from "../Middleware";
3
3
  import {Request} from "../Request";
4
4
 
5
+ /* Lifts the two cookies authentication cares about off the request: this
6
+ platform's own session, and the identity the central login proved. The
7
+ first of each name wins, which is what a browser sends first. */
5
8
  class SessionMiddleware implements Middleware {
6
9
 
7
10
  async apply(request : Request) : Promise<boolean> {
8
11
  const cookies = String(request.header("cookie") ?? "").split(";");
9
12
  for (const cookie of cookies) {
10
13
  const [name, ...value] = cookie.trim().split("=");
11
- if (name === SESSION_COOKIE) {
12
- request.session = value.join("=");
13
- break;
14
- }
14
+ if (name === SESSION_COOKIE && request.session === undefined) request.session = value.join("=");
15
+ if (name === IDENTITY_COOKIE && request.identity === undefined) request.identity = value.join("=");
15
16
  }
16
17
  return true;
17
18
  }