diff --git a/package.json b/package.json index 5c860e87..64f5bd2c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "build:dashboard": "bun run ./scripts/build/dashboard.ts", "db:gen": "cd src/databases/officer_db && bun run generate", "db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push", - "postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts", + "postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts", "db:migrate": "cd src/databases/officer_db && bun run migrate", "dev:emailer": "cd src/workspaces/emailer && bun run dev", "format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write", diff --git a/scripts/seed-roles.ts b/scripts/seed-roles.ts new file mode 100644 index 00000000..f459b087 --- /dev/null +++ b/scripts/seed-roles.ts @@ -0,0 +1,53 @@ +/** + * Seed the `roles` table with the roles the platform's own code names. + * + * Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a + * per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same + * mechanism `backfill-session-models.ts` relies on. + * + * ── Why these four are `isSystem` ── + * + * Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`, + * `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one + * `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it + * is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on, + * rather than for the failure to turn up later as a missing database role or an account that cannot log in. + * + * ── What this deliberately does NOT do ── + * + * No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty + * and adds the constraint in the same run, so every existing account violates it — verified against a + * scratch database on 2026-08-23, where push reported the violation, created the table, skipped the + * constraint, and exited 0. The FK is a separate release, once every host holds these rows. + * + * So today `users.role` is still a plain text column and nothing enforces that its value is a real role. + * That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts + * unattended. + * + * Never overwrites. A description edited on one server must not be reverted by the next deploy. + */ +import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index'; + +const SEED: { name: string; description: string }[] = [ + { name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' }, + { name: 'Admin', description: 'Administers the platform. No operating-system privilege' }, + { name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' }, + { name: 'Member', description: 'The default. Reaches only what their role has been granted' }, +]; + +async function main(): Promise { + await waitForDatabase(); + let created = 0; + for (const role of SEED) { + if (await seedRole({ ...role, isSystem: true })) created += 1; + } + console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`); +} + +// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make +// a successful push look like a failed one — the rows are additive and the next push tries again. +main() + .catch((err: unknown) => { + console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err); + }) + .finally(() => process.exit(0)); diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 4cd5916a..6a5e2caa 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -41,6 +41,7 @@ export * from './anthropic-accounts'; export * from './theme-favorites'; export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup'; export * from './sendoff'; +export * from './roles'; export * from './command-hq'; export * from './dashboards'; export * from './integrations'; diff --git a/src/databases/officer_db/src/roles/index.ts b/src/databases/officer_db/src/roles/index.ts new file mode 100644 index 00000000..23bff5c4 --- /dev/null +++ b/src/databases/officer_db/src/roles/index.ts @@ -0,0 +1,2 @@ +export { roles } from './schema'; +export { listRoles, getRole, seedRole, type RoleRow } from './queries'; diff --git a/src/databases/officer_db/src/roles/queries.ts b/src/databases/officer_db/src/roles/queries.ts new file mode 100644 index 00000000..7cd3eaab --- /dev/null +++ b/src/databases/officer_db/src/roles/queries.ts @@ -0,0 +1,61 @@ +import { asc, eq, sql } from 'drizzle-orm'; +import { db } from '../db'; +import { users } from '../auth/schema'; +import { roles } from './schema'; + +export type RoleRow = { + name: string; + description: string | null; + isSystem: boolean; + provisioningScript: string | null; + createdAt: Date; +}; + +/** + * Every role, with how many accounts hold each. + * + * The count is here rather than in a second query because every caller needs it: a picker wants to show + * it, and the delete guard is defined in terms of it. One left join beats two round trips and a race + * between them. + */ +export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> { + const rows = await db + .select({ + name: roles.name, + description: roles.description, + isSystem: roles.isSystem, + provisioningScript: roles.provisioningScript, + createdAt: roles.createdAt, + userCount: sql`count(${users.id})::int`, + }) + .from(roles) + .leftJoin(users, eq(users.role, roles.name)) + .groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt) + .orderBy(asc(roles.name)); + return rows; +} + +export async function getRole(name: string): Promise { + const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1); + return row; +} + +/** + * Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row. + * + * Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this + * platform's life. An upsert would mean a description edited on one server is silently reverted by the next + * deploy — so the seed establishes a row and never touches it again. + */ +export async function seedRole(row: { + name: string; + description?: string | null; + isSystem?: boolean; +}): Promise { + const inserted = await db + .insert(roles) + .values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false }) + .onConflictDoNothing({ target: roles.name }) + .returning({ name: roles.name }); + return inserted.length > 0; +} diff --git a/src/databases/officer_db/src/roles/schema.ts b/src/databases/officer_db/src/roles/schema.ts new file mode 100644 index 00000000..44be6665 --- /dev/null +++ b/src/databases/officer_db/src/roles/schema.ts @@ -0,0 +1,52 @@ +import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core'; + +/** + * The roles this server has, as data rather than a TypeScript union. + * + * ── Why the NAME is the primary key ── + * + * `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off + * the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one + * file standing between a Member and a shell — to buy nothing the name does not already give, because + * Postgres will cascade a rename for us. So both columns become foreign keys onto this one with + * `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code + * changes. + * + * The cost is that a rename is a data migration rather than a label edit. That is honest — a role name + * appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down. + * + * ── What is NOT here ── + * + * **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this + * table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that + * making roles editable would have made "add a role" and "become the owner" the same feature. Several + * accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it. + * + * **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and + * its provisioning, not its grants. + */ +export const roles = pgTable('roles', { + /** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */ + name: text('name').primaryKey(), + /** One line, for a picker. Never load-bearing — nothing branches on it. */ + description: text('description'), + /** + * A role the platform's own code names, so it cannot be deleted or renamed. + * + * `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the + * users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a + * code change that has not happened yet — so the delete and rename paths refuse on this flag rather than + * discovering it as a runtime failure somewhere else. + */ + isSystem: boolean('is_system').notNull().default(false), + /** + * Reserved for the per-role provisioning that comes next: a script run when an account is given this + * role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and + * becomes a row. Null today, and nothing reads it. + * + * Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a + * column that arrives with the feature that uses it is a second migration for no gain. + */ + provisioningScript: text('provisioning_script'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 9f714397..99d5d4c1 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags export * from './sendoff/schema'; // sendoff_devices, sendoff_senders +export * from './roles/schema'; // roles export * from './agent-panels/schema'; // agent_panels // Core because the tailnet is the perimeter — a security model resting on it cannot treat administering