anbaric-cloud-hosting 1.33.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 +7 -7
- package/src/auth/InMemoryUserDirectory.ts +29 -0
- package/src/auth/SessionSigner.ts +4 -2
- package/src/auth/UserDirectory.ts +25 -0
- package/src/data-store/PostgresUserDirectory.ts +39 -0
- package/src/data-store/Schema.ts +10 -0
- package/src/hosting/HostingServer.ts +6 -2
- package/src/hosting/handlers/auth/UsersHandler.ts +16 -0
- package/src/hosting/middleware/AuthenticationMiddleware.ts +28 -2
- package/src/hosting/pages/platform-ui.html +3 -3
- package/src/index.ts +4 -0
- package/src/main.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
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
|
-
"anbaric-state-machine": "^1.
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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 }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -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,9 @@ 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";
|
|
19
21
|
import {EntitlementsAdminHandler} from "./handlers/EntitlementsAdminHandler";
|
|
@@ -62,7 +64,8 @@ class HostingServer {
|
|
|
62
64
|
plugins : Array<LoadedPlugin> = [],
|
|
63
65
|
jobRunSchedules? : JobRunSchedulePersistence,
|
|
64
66
|
notifier? : Notifier,
|
|
65
|
-
entitlements? : EntitlementStore
|
|
67
|
+
entitlements? : EntitlementStore,
|
|
68
|
+
userDirectory? : UserDirectory) {
|
|
66
69
|
const pages = new PagesHandler();
|
|
67
70
|
const ping = new PingHandler(tenant);
|
|
68
71
|
const jobs = new JobsHandler(persistence);
|
|
@@ -92,6 +95,7 @@ class HostingServer {
|
|
|
92
95
|
if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
|
|
93
96
|
if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
|
|
94
97
|
if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
|
|
98
|
+
if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
|
|
95
99
|
if (cliAuthorizer) {
|
|
96
100
|
publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
|
|
97
101
|
publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
|
|
@@ -128,7 +132,7 @@ class HostingServer {
|
|
|
128
132
|
|
|
129
133
|
this.publicServer = new Server(publicRouter, [
|
|
130
134
|
new SessionMiddleware(),
|
|
131
|
-
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
|
|
135
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
|
|
132
136
|
]);
|
|
133
137
|
this.internalServer = new Server(internalRouter);
|
|
134
138
|
}
|
|
@@ -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
|
}
|