Compare commits
6 Commits
9f7216565b
...
9e1909a7e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e1909a7e8 | |||
| 3058bccfb6 | |||
| 604f6556fe | |||
| ca7c6b408e | |||
| 1c1f280553 | |||
| f1ca33f2a2 |
@@ -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).
|
||||
|
||||
@@ -192,6 +192,7 @@ export class AuthService {
|
||||
name: n.name,
|
||||
endpoint: n.endpoint,
|
||||
status: n.status,
|
||||
purpose: n.purpose ?? null,
|
||||
}));
|
||||
|
||||
const guildAccessTokens = guilds.map((g) => ({
|
||||
@@ -204,6 +205,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) {
|
||||
|
||||
39
src/cli.ts
39
src/cli.ts
@@ -15,7 +15,11 @@ 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 node set-purpose --node-id <id> --purpose <text>');
|
||||
console.error(
|
||||
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
|
||||
' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
|
||||
@@ -52,6 +56,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');
|
||||
@@ -64,6 +92,17 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'node' && action === 'set-purpose') {
|
||||
const nodeId = getArg('--node-id');
|
||||
const purpose = getArg('--purpose');
|
||||
if (!nodeId || purpose === null) printUsageAndExit();
|
||||
|
||||
const nodes = app.get(NodeAdminService);
|
||||
const result = await nodes.setPurpose(nodeId, purpose);
|
||||
process.stdout.write(JSON.stringify({ ok: true, node: result }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'config' && action === 'oidc') {
|
||||
const oidc = app.get(OidcService);
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
@@ -20,6 +20,15 @@ export class GuildNode {
|
||||
@Column()
|
||||
endpoint!: string;
|
||||
|
||||
// Free-form description of this guild's purpose — what it's used for,
|
||||
// who its agents/users serve. Surfaced to agents via /auth/me/guilds so
|
||||
// the cross-guild/cross-channel announce-target picker can find the
|
||||
// right guild by intent ("which guild is about debate broadcasts?")
|
||||
// without anything hard-coded into agent workflows. admin-only write
|
||||
// (cli: `node set-purpose --node-id X --purpose Y`).
|
||||
@Column({ type: 'text', nullable: true })
|
||||
purpose!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
apiKeyHash!: string | null;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -55,5 +55,26 @@ export class NodeAdminService {
|
||||
apiKey: rawApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// Admin-only via cli (never HTTP): set the free-form purpose string on
|
||||
// a guild node. Pass an empty string to clear it (null).
|
||||
async setPurpose(nodeId: string, purpose: string) {
|
||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||
if (!node) throw new NotFoundException(`node ${nodeId} not found`);
|
||||
const trimmed = String(purpose ?? '').trim();
|
||||
node.purpose = trimmed === '' ? null : trimmed;
|
||||
const saved = await this.nodeRepo.save(node);
|
||||
await this.audit.write({
|
||||
action: 'node.set_purpose',
|
||||
targetType: 'node',
|
||||
targetId: saved.nodeId,
|
||||
detail: JSON.stringify({ purpose: saved.purpose, via: 'cli' }),
|
||||
});
|
||||
return {
|
||||
nodeId: saved.nodeId,
|
||||
name: saved.name,
|
||||
purpose: saved.purpose,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export class NodesController {
|
||||
name: n.name,
|
||||
endpoint: n.endpoint,
|
||||
status: n.status,
|
||||
purpose: n.purpose ?? null,
|
||||
lastHeartbeatAt: n.lastHeartbeatAt,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
|
||||
Reference in New Issue
Block a user