From f1ca33f2a2fa6ea40e9465ba3695e1ebf119fce6 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 22 May 2026 22:09:58 +0100 Subject: [PATCH 1/2] feat(auth): center-scoped single admin + GET /admin-email + cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage channels in Guilds need to deliver every message to a single "observer" admin user across the whole Center deployment (regardless of on-duty / mention). This adds the data + auth surface for that. ## Schema `users.isAdmin TINYINT DEFAULT 0` (synchronize:true auto-applies; cli `user set-admin` enforces at-most-one in a transaction). ## CLI (subject `user`) - `set-admin --email ` — clears every other admin row first, then marks the target. Returns `{admin: {email, userId}}` - `clear-admin` — unsets all (returns `{cleared: N}`) - `show-admin` — prints `{admin: {email, userId}}` or `{admin: null}` ## HTTP `GET /auth/admin-email` (NOT @Public — requires a guild-node api key via the existing CenterApiKeyGuard). Returns: - `{email: "...", userId: "..."}` if an admin exists - `null` (literal JSON) if no admin Guild backends cache the result (1 day TTL per spec; with cli refresh override on guild side). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/auth/auth.controller.ts | 9 +++++++ src/auth/auth.service.ts | 50 +++++++++++++++++++++++++++++++++++++ src/cli.ts | 27 ++++++++++++++++++++ src/entities/user.entity.ts | 13 ++++++++++ 4 files changed, 99 insertions(+) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d6d12a7..fefe79a 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -79,6 +79,15 @@ export class AuthController { return this.authService.agentLogin(String(body?.apiKey ?? '')); } + // Not @Public(): requires a guild node api key. Returns the single + // Center-scoped admin user `{email, userId}` so the calling guild can + // mark them as triage observers, or `null` if no admin is set. + @Get('admin-email') + getAdminEmail(@Req() req: { guildNode?: AuthedGuildNode }) { + if (!req.guildNode) throw new UnauthorizedException('guild node api key required'); + return this.authService.getAdminEmail(); + } + // Not @Public(): requires a guild node api key. The guard attaches the // authenticated node; a node may only introspect tokens for its own // guild (Center C2 — prevents cross-tenant identity probing). diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 209ba2d..6a23ad5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -204,6 +204,56 @@ export class AuthService { return { guilds, guildAccessTokens }; } + // ------------------------------------------------------------------- + // Center-scoped admin (at most one per deployment). + // + // setAdmin / clearAdmin are reachable only via cli, not HTTP — admin + // promotion bypasses every HTTP gate (the admin observes ALL triage + // traffic) so we don't want it tunneled through any token / api-key. + // + // getAdminEmail is exposed to guild backends (api-key auth) so each + // guild can mark the admin as a triage observer. + // ------------------------------------------------------------------- + + async getAdminEmail(): Promise<{ email: string; userId: string } | null> { + const admin = await this.userRepo.findOne({ where: { isAdmin: true } }); + if (!admin) return null; + return { email: admin.email, userId: admin.id }; + } + + async setAdmin(email: string): Promise<{ email: string; userId: string }> { + const target = await this.userRepo.findOne({ where: { email } }); + if (!target) { + throw new UnauthorizedException(`user ${email} not found`); + } + // Enforce at-most-one-admin: clear every other row first, then set + // the target. Two UPDATEs in one txn so a peer never sees two admins. + await this.userRepo.manager.transaction(async (txn) => { + await txn.update(User, {}, { isAdmin: false }); + await txn.update(User, { id: target.id }, { isAdmin: true }); + }); + await this.audit.write({ + action: 'admin.set', + actorId: target.id, + targetType: 'user', + targetId: target.id, + detail: JSON.stringify({ email: target.email }), + }); + return { email: target.email, userId: target.id }; + } + + async clearAdmin(): Promise<{ cleared: number }> { + const res = await this.userRepo.update({ isAdmin: true }, { isAdmin: false }); + await this.audit.write({ + action: 'admin.clear', + actorId: 'cli', + targetType: 'user', + targetId: 'all', + detail: JSON.stringify({ cleared: res.affected ?? 0 }), + }); + return { cleared: res.affected ?? 0 }; + } + async register(input: RegisterDto) { const exists = await this.userRepo.findOne({ where: { email: input.email } }); if (exists) { diff --git a/src/cli.ts b/src/cli.ts index dfbdb03..a1b41d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,9 @@ function printUsageAndExit(): never { console.error('Usage:'); console.error(' node dist/cli.js user create --email --password '); console.error(' node dist/cli.js user apikey --email [--label