Merge pull request 'feat(auth): center-scoped single admin + GET /admin-email + cli' (#1) from feat/admin-user into main
This commit is contained in:
@@ -79,6 +79,15 @@ export class AuthController {
|
|||||||
return this.authService.agentLogin(String(body?.apiKey ?? ''));
|
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
|
// Not @Public(): requires a guild node api key. The guard attaches the
|
||||||
// authenticated node; a node may only introspect tokens for its own
|
// authenticated node; a node may only introspect tokens for its own
|
||||||
// guild (Center C2 — prevents cross-tenant identity probing).
|
// guild (Center C2 — prevents cross-tenant identity probing).
|
||||||
|
|||||||
@@ -204,6 +204,58 @@ export class AuthService {
|
|||||||
return { guilds, guildAccessTokens };
|
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) {
|
async register(input: RegisterDto) {
|
||||||
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
|||||||
27
src/cli.ts
27
src/cli.ts
@@ -15,6 +15,9 @@ function printUsageAndExit(): never {
|
|||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
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 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 node register --node-id <id> --name <name> --endpoint <url>');
|
||||||
console.error(
|
console.error(
|
||||||
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
|
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
|
||||||
@@ -52,6 +55,30 @@ async function main() {
|
|||||||
return;
|
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') {
|
if (subject === 'node' && action === 'register') {
|
||||||
const nodeId = getArg('--node-id');
|
const nodeId = getArg('--node-id');
|
||||||
const name = getArg('--name');
|
const name = getArg('--name');
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ export class User {
|
|||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
refreshTokenHash!: string | null;
|
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()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user