Compare commits

3 Commits

Author SHA1 Message Date
1c1f280553 fix(cli): set-admin transaction uses isAdmin=true filter (TypeORM rejects empty-where)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:25:06 +01:00
f1ca33f2a2 feat(auth): center-scoped single admin + GET /admin-email + cli
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 <e>` — 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) <noreply@anthropic.com>
2026-05-22 22:09:58 +01:00
9f7216565b fix(center): graceful 4xx when OIDC issuer/token endpoint unreachable
Wrap discovery + token-exchange fetch in try/catch so a DNS/network
failure returns BadRequest/Unauthorized instead of a bare 500.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:28 +01:00
5 changed files with 117 additions and 6 deletions

View File

@@ -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).

View File

@@ -204,6 +204,58 @@ 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 existing admin row first
// (TypeORM rejects an empty-where update, so target by isAdmin=true
// — the no-op cost when no rows match is one cheap query), 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: true }, { 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) {

View File

@@ -62,7 +62,12 @@ export class OidcService {
return this.discoveryCache.doc;
}
const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const res = await fetch(url);
let res: Response;
try {
res = await fetch(url);
} catch {
throw new BadRequestException('oidc: issuer unreachable (discovery failed)');
}
if (!res.ok) throw new BadRequestException(`oidc discovery failed: ${res.status}`);
const doc = (await res.json()) as Discovery;
if (!doc.authorization_endpoint || !doc.token_endpoint) {
@@ -120,11 +125,16 @@ export class OidcService {
client_secret: c.clientSecret,
code_verifier: st.cv,
});
const tokRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
});
let tokRes: Response;
try {
tokRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
});
} catch {
throw new UnauthorizedException('oidc: token endpoint unreachable');
}
if (!tokRes.ok) {
throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`);
}

View File

@@ -15,6 +15,9 @@ function printUsageAndExit(): never {
console.error('Usage:');
console.error(' node dist/cli.js user create --email <email> --password <password>');
console.error(' node dist/cli.js user apikey --email <email> [--label <label>]');
console.error(' node dist/cli.js user set-admin --email <email>');
console.error(' node dist/cli.js user clear-admin');
console.error(' node dist/cli.js user show-admin');
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
console.error(
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
@@ -52,6 +55,30 @@ async function main() {
return;
}
if (subject === 'user' && action === 'set-admin') {
const email = getArg('--email');
if (!email) printUsageAndExit();
const auth = app.get(AuthService);
const res = await auth.setAdmin(email);
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
return;
}
if (subject === 'user' && action === 'clear-admin') {
const auth = app.get(AuthService);
const res = await auth.clearAdmin();
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
return;
}
if (subject === 'user' && action === 'show-admin') {
const auth = app.get(AuthService);
const res = await auth.getAdminEmail();
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
return;
}
if (subject === 'node' && action === 'register') {
const nodeId = getArg('--node-id');
const name = getArg('--name');

View File

@@ -17,6 +17,19 @@ export class User {
@Column({ type: 'varchar', length: 255, nullable: true })
refreshTokenHash!: string | null;
/**
* Center-scoped admin flag. At most one user should have this set at a
* time; the cli `user set-admin` enforces single-admin by clearing all
* other admins before promoting the target user. Guild backends learn
* the current admin via `GET /admin-email`.
*
* Use: triage channels deliver every message to the admin as a silent
* observer (wake=false) regardless of on-duty / mention status, so a
* single human can audit all triage traffic.
*/
@Column({ type: 'tinyint', default: 0, name: 'isAdmin' })
isAdmin!: boolean;
@CreateDateColumn()
createdAt!: Date;
}