diff --git a/kloe.schema.json b/kloe.schema.json index cc9bf4e..79a75e4 100644 --- a/kloe.schema.json +++ b/kloe.schema.json @@ -423,6 +423,10 @@ }, "default": [] }, + "ownerRole": { + "type": "string", + "default": "" + }, "sessionTtlDays": { "type": "integer", "minimum": 1, @@ -446,6 +450,7 @@ "clientSecret": "", "allowedSubs": [], "owners": [], + "ownerRole": "", "sessionTtlDays": 30, "appName": "kloe", "logoUri": "" diff --git a/src/auth.ts b/src/auth.ts index 14b591c..8afc36e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -221,6 +221,8 @@ interface TokenResponse { me?: string; sub?: string; iss?: string; + /** indiko's per-app RBAC: an arbitrary string an admin assigned for THIS app. */ + role?: string; profile?: { name?: string; email?: string; photo?: string; picture?: string; url?: string }; } @@ -273,7 +275,7 @@ export async function handleCallback(req: Request, store: Store): Promise["auth"]): boolean { + return cfg.owners.length > 0 || cfg.ownerRole !== ""; +} + +/** + * Owner or guest, from two sources that answer in this order: + * + * 1. `auth.owners` — subjects named in the deployment's own config. This is + * the break-glass: it holds when the provider is misconfigured, when a + * role was never assigned, and when you are the person fixing it. + * 2. `auth.ownerRole` — the provider's role for this app, recorded on the + * session at sign-in. indiko assigns these per app, so kloe asks for + * nothing beyond what an admin already clicked there. + * + * Anyone else who gets through `allowedSubs` is a guest. + */ +export function roleFor(sub: string | undefined, providerRole?: string): Role { const cfg = getConfig().auth; - if (!cfg.enabled || cfg.owners.length === 0) return "owner"; - return sub && cfg.owners.includes(sub) ? "owner" : "guest"; + if (!cfg.enabled || !rolesInPlay(cfg)) return "owner"; + if (sub && cfg.owners.includes(sub)) return "owner"; + if (cfg.ownerRole && providerRole === cfg.ownerRole) return "owner"; + return "guest"; } /** The role of whoever made this request. */ export function requestRole(req: Request, store: Store): Role { - return roleFor(getSession(req, store)?.sub); + const s = getSession(req, store); + return roleFor(s?.sub, s?.role); } /** The public shape of the signed-in user, for `/api/me`. */ @@ -344,5 +371,7 @@ export function sessionUser(session: Session): { url?: string; email?: string; } { - return { sub: session.sub, role: roleFor(session.sub), ...session.profile }; + // `role` here is kloe's own answer (owner | guest), not the provider's raw + // string — the profile spread comes first so it can never shadow it. + return { ...session.profile, sub: session.sub, role: roleFor(session.sub, session.role) }; } diff --git a/src/settings.ts b/src/settings.ts index 2da0e28..19a6321 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -395,6 +395,16 @@ const AuthSchema = v.object({ * and what every instance was before roles existed. */ owners: v.optional(v.array(v.string()), []), + /** + * The provider's role string that means "owner" — indiko assigns roles per + * app (an admin picks them on the pre-registered client), and hands the + * user's role back in the token response, so this is the mechanism that + * scales past editing config for every person. + * + * Empty (the default) means only `owners` decides. Setting either one is + * what tells kloe this deployment has guests at all. + */ + ownerRole: v.optional(v.string(), ""), sessionTtlDays: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 30), appName: v.optional(v.string(), "kloe"), logoUri: v.optional(v.string(), ""), diff --git a/src/store.ts b/src/store.ts index eef6e44..9bf3481 100644 --- a/src/store.ts +++ b/src/store.ts @@ -439,7 +439,8 @@ CREATE TABLE IF NOT EXISTS sessions ( sub TEXT NOT NULL, data TEXT NOT NULL, created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + role TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at); @@ -552,6 +553,15 @@ export interface Session { sub: string; expiresAt: number; profile: SessionProfile; + /** + * The role the identity provider reported for this app at sign-in, verbatim + * (indiko's per-app RBAC). Undefined when the provider sent none. + * + * Captured once, at login, like every other claim in the session — so a role + * changed upstream takes effect the next time that person signs in, not + * mid-session. Revoking someone promptly means deleting their sessions. + */ + role?: string; } /** @@ -622,6 +632,12 @@ export class Store { } catch { // column already exists } + // Migration: the provider's role for this app, recorded at sign-in. + try { + this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT"); + } catch { + // column already exists + } // Migration: curation gained a guest dimension. Existing rows default to // 0 — a deployment that adds guests decides what they may reach, rather // than inheriting the owner's whole list the moment roles arrive. @@ -1335,17 +1351,25 @@ export class Store { // ---- auth sessions ----------------------------------------------------- /** Creates a session; `id` is the opaque high-entropy cookie value. */ - createSession(id: string, sub: string, profile: SessionProfile, expiresAt: number): void { + createSession( + id: string, + sub: string, + profile: SessionProfile, + expiresAt: number, + role?: string, + ): void { this.db - .query("INSERT INTO sessions (id, sub, data, created_at, expires_at) VALUES (?, ?, ?, ?, ?)") - .run(id, sub, JSON.stringify(profile), Date.now(), expiresAt); + .query( + "INSERT INTO sessions (id, sub, data, created_at, expires_at, role) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run(id, sub, JSON.stringify(profile), Date.now(), expiresAt, role ?? null); } /** The session for a cookie id, or undefined if missing/expired (expired rows are dropped). */ getSession(id: string): Session | undefined { const row = this.db - .query("SELECT sub, data, expires_at FROM sessions WHERE id = ?") - .get(id) as { sub: string; data: string; expires_at: number } | null; + .query("SELECT sub, data, expires_at, role FROM sessions WHERE id = ?") + .get(id) as { sub: string; data: string; expires_at: number; role: string | null } | null; if (!row) return undefined; if (row.expires_at <= Date.now()) { this.deleteSession(id); @@ -1356,6 +1380,7 @@ export class Store { sub: row.sub, expiresAt: row.expires_at, profile: JSON.parse(row.data) as SessionProfile, + role: row.role ?? undefined, }; } diff --git a/tests/roles.test.ts b/tests/roles.test.ts index c04afb9..cb0209c 100644 --- a/tests/roles.test.ts +++ b/tests/roles.test.ts @@ -39,9 +39,9 @@ function fixtureRegistry(): ProviderRegistry { }); } -function configure(owners: string[]): void { +function configure(owners: string[], ownerRole = ""): void { const base = loadConfig({ path: "does-not-exist.json", env: {} }); - setConfig({ ...base, auth: { ...base.auth, enabled: true, owners } }); + setConfig({ ...base, auth: { ...base.auth, enabled: true, owners, ownerRole } }); resetAuthCache(); } @@ -63,9 +63,9 @@ afterAll(() => { /** A session cookie for `sub`, so a request arrives as that person. */ let sid = 0; -function as(store: Store, sub: string): { cookie: string } { +function as(store: Store, sub: string, providerRole?: string): { cookie: string } { const id = `sid-${sub}-${sid++}`; - store.createSession(id, sub, {}, Date.now() + 600_000); + store.createSession(id, sub, {}, Date.now() + 600_000, providerRole); return { cookie: `kloe_session=${encodeURIComponent(id)}` }; } @@ -191,3 +191,55 @@ test("guest visibility survives a round trip through the patch endpoint", async sortOrder: 0, }); }); + +// ---- roles from the identity provider -------------------------------------- +// indiko assigns a role per app and returns it in the token response, so the +// list of who runs this instance can live where the accounts already do. + +test("the provider's role decides, when the deployment says which role means owner", () => { + configure([], "operator"); + expect(roleFor("https://anyone/", "operator")).toBe("owner"); + expect(roleFor("https://anyone/", "viewer")).toBe("guest"); + // No role assigned upstream is a guest, not an error. + expect(roleFor("https://anyone/", undefined)).toBe("guest"); +}); + +test("naming the role is enough to bring roles into play", () => { + // Without `owners`, an earlier version treated everyone as an owner; a + // deployment that has said which role means owner has plainly opted in. + configure([], "operator"); + expect(roleFor("https://anyone/", undefined)).toBe("guest"); +}); + +test("the configured owner list outranks whatever the provider says", () => { + configure([OWNER], "operator"); + // The break-glass: it holds when the role was never assigned, which is the + // state every pre-registered app starts in. + expect(roleFor(OWNER, undefined)).toBe("owner"); + expect(roleFor(OWNER, "viewer")).toBe("owner"); + expect(roleFor(GUEST, "operator")).toBe("owner"); + expect(roleFor(GUEST, undefined)).toBe("guest"); +}); + +test("a session carries the role it was signed in with", async () => { + configure([], "operator"); + const { base, store } = freshApp(); + store.setModelSetting({ + ref: "acme/spendy", + visible: true, + guestVisible: false, + displayName: null, + sortOrder: 0, + }); + + const me = await ( + await fetch(`${base}/api/me`, { headers: as(store, GUEST, "operator") }) + ).json(); + expect((me as any).role).toBe("owner"); + + // …and the same person without the role is held to the guest set. + const forGuest = await fetch(`${base}/api/models/chat`, { + headers: as(store, GUEST, "viewer"), + }); + expect(((await forGuest.json()) as any).models).toEqual([]); +});