@adonis-agora/authkit-server 0.78.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/agents/config.d.ts +2 -0
- package/build/src/agents/config.js +3 -0
- package/build/src/agents/delegation_store.d.ts +16 -0
- package/build/src/agents/delegation_store.js +44 -0
- package/build/src/agents/poppy/config.d.ts +3 -0
- package/build/src/agents/poppy/config.js +7 -0
- package/build/src/agents/poppy/service.d.ts +6 -0
- package/build/src/agents/poppy/service.js +12 -0
- package/build/src/agents/poppy/store.d.ts +2 -0
- package/build/src/agents/poppy/store.js +9 -0
- package/build/src/agents/registry_store.d.ts +41 -0
- package/build/src/agents/registry_store.js +138 -0
- package/build/src/agents/runtime.d.ts +13 -0
- package/build/src/agents/runtime.js +87 -5
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_console/admin_shell_controller.js +7 -0
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +83 -0
- package/build/src/host/admin_console/console_personal_agents_controller.js +337 -0
- package/build/src/host/register_auth_host.js +16 -0
- package/build/src/host/ui-dist/assets/index-LrScaR9C.js +155 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/schema/ensure.js +28 -0
- package/package.json +2 -2
- package/build/src/host/ui-dist/assets/index-CIqtoNcO.js +0 -155
|
@@ -103,6 +103,8 @@ export interface ResolvedPersonalAgentsConfig {
|
|
|
103
103
|
/** `''` quando o PACT está desligado. */
|
|
104
104
|
audience: string;
|
|
105
105
|
resolveAgent: PersonalAgentResolver;
|
|
106
|
+
/** A lista `agents` do config quando é um array (o console mostra); `null` = resolver do host. */
|
|
107
|
+
staticAgents: PersonalAgentRegistration[] | null;
|
|
106
108
|
open: boolean;
|
|
107
109
|
delegation?: ResolvedPersonalAgentDelegationConfig;
|
|
108
110
|
prefix: string;
|
|
@@ -51,10 +51,12 @@ export function resolvePersonalAgentsConfig(input) {
|
|
|
51
51
|
throw new Error('authkit: o Poppy não é um `protocol` do PACT — configure-o em `personalAgents.poppy` (os dois convivem).');
|
|
52
52
|
}
|
|
53
53
|
let resolveAgent;
|
|
54
|
+
let staticAgents = null;
|
|
54
55
|
if (typeof input.agents === 'function') {
|
|
55
56
|
resolveAgent = input.agents;
|
|
56
57
|
}
|
|
57
58
|
else {
|
|
59
|
+
staticAgents = [...(input.agents ?? [])];
|
|
58
60
|
const byIssuer = new Map();
|
|
59
61
|
for (const agent of input.agents ?? []) {
|
|
60
62
|
assertUrl(agent.issuer, `agents[].issuer`);
|
|
@@ -101,6 +103,7 @@ export function resolvePersonalAgentsConfig(input) {
|
|
|
101
103
|
protocol,
|
|
102
104
|
audience: pact ? input.audience : '',
|
|
103
105
|
resolveAgent,
|
|
106
|
+
staticAgents,
|
|
104
107
|
open: input.open === true,
|
|
105
108
|
delegation,
|
|
106
109
|
prefix,
|
|
@@ -37,7 +37,13 @@ export interface GrantRow {
|
|
|
37
37
|
createdAt: Date;
|
|
38
38
|
updatedAt: Date;
|
|
39
39
|
}
|
|
40
|
+
export interface LiveGrantFilter {
|
|
41
|
+
protocol?: 'pact' | 'poppy';
|
|
42
|
+
clientId?: string;
|
|
43
|
+
accountId?: string;
|
|
44
|
+
}
|
|
40
45
|
export declare class DelegationStore {
|
|
46
|
+
#private;
|
|
41
47
|
private conn;
|
|
42
48
|
/** `conn` = uma conexão Lucid (`db.connection(name?)`) — ou uma transação. */
|
|
43
49
|
constructor(conn: () => any);
|
|
@@ -75,6 +81,16 @@ export declare class DelegationStore {
|
|
|
75
81
|
/** Revoga um grant DA CONTA. `false` = não existe, é de outra conta ou já revogado. */
|
|
76
82
|
revokeGrant(accountId: string, id: string, at: Date): Promise<boolean>;
|
|
77
83
|
revokeAllGrants(accountId: string, at: Date): Promise<number>;
|
|
84
|
+
listLiveGrants(input: LiveGrantFilter & {
|
|
85
|
+
now: Date;
|
|
86
|
+
offset: number;
|
|
87
|
+
limit: number;
|
|
88
|
+
}): Promise<GrantRow[]>;
|
|
89
|
+
countLiveGrants(now: Date, filter?: LiveGrantFilter): Promise<number>;
|
|
90
|
+
/** Conexões vivas de um protocolo por agente: `client_id` → número de grants. */
|
|
91
|
+
countLiveGrantsByClient(now: Date, protocol: 'pact' | 'poppy'): Promise<Map<string, number>>;
|
|
92
|
+
/** Ids dos grants não revogados de um agente (vivos ou não) — para revogar todos. */
|
|
93
|
+
unrevokedGrantIdsOfClient(clientId: string): Promise<string[]>;
|
|
78
94
|
/** Revoga um grant sem checar a conta — reação a reuso de refresh token. */
|
|
79
95
|
revokeGrantById(id: string, at: Date): Promise<void>;
|
|
80
96
|
insertRefreshToken(row: {
|
|
@@ -183,6 +183,50 @@ export class DelegationStore {
|
|
|
183
183
|
.update({ revoked_at: at, updated_at: at });
|
|
184
184
|
return affected(result);
|
|
185
185
|
}
|
|
186
|
+
// ─── console admin (todas as contas) ──────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* Grants vivos (não revogados nem expirados em `now`), de todas as contas. O
|
|
189
|
+
* protocolo sai do prefixo do id: `agrant_` (PACT) ou `pgrant_` (Poppy).
|
|
190
|
+
*/
|
|
191
|
+
#live(now, filter = {}) {
|
|
192
|
+
let q = this.conn().from(GRANT_TABLE).whereNull('revoked_at').where('expires_at', '>', now);
|
|
193
|
+
if (filter.protocol)
|
|
194
|
+
q = q.where('id', 'like', filter.protocol === 'poppy' ? 'pgrant_%' : 'agrant_%');
|
|
195
|
+
if (filter.clientId)
|
|
196
|
+
q = q.where('client_id', filter.clientId);
|
|
197
|
+
if (filter.accountId)
|
|
198
|
+
q = q.where('account_id', filter.accountId);
|
|
199
|
+
return q;
|
|
200
|
+
}
|
|
201
|
+
async listLiveGrants(input) {
|
|
202
|
+
const rows = await this.#live(input.now, input)
|
|
203
|
+
.orderBy('created_at', 'desc')
|
|
204
|
+
.orderBy('id', 'asc')
|
|
205
|
+
.offset(input.offset)
|
|
206
|
+
.limit(input.limit);
|
|
207
|
+
return rows.map(toGrant);
|
|
208
|
+
}
|
|
209
|
+
async countLiveGrants(now, filter = {}) {
|
|
210
|
+
const row = await this.#live(now, filter).count('* as total').first();
|
|
211
|
+
return Number(row?.total ?? 0);
|
|
212
|
+
}
|
|
213
|
+
/** Conexões vivas de um protocolo por agente: `client_id` → número de grants. */
|
|
214
|
+
async countLiveGrantsByClient(now, protocol) {
|
|
215
|
+
const rows = await this.#live(now, { protocol })
|
|
216
|
+
.select('client_id')
|
|
217
|
+
.count('* as total')
|
|
218
|
+
.groupBy('client_id');
|
|
219
|
+
return new Map(rows.map((r) => [r.client_id, Number(r.total)]));
|
|
220
|
+
}
|
|
221
|
+
/** Ids dos grants não revogados de um agente (vivos ou não) — para revogar todos. */
|
|
222
|
+
async unrevokedGrantIdsOfClient(clientId) {
|
|
223
|
+
const rows = await this.conn()
|
|
224
|
+
.from(GRANT_TABLE)
|
|
225
|
+
.where('client_id', clientId)
|
|
226
|
+
.whereNull('revoked_at')
|
|
227
|
+
.select('id');
|
|
228
|
+
return rows.map((r) => r.id);
|
|
229
|
+
}
|
|
186
230
|
/** Revoga um grant sem checar a conta — reação a reuso de refresh token. */
|
|
187
231
|
async revokeGrantById(id, at) {
|
|
188
232
|
await this.conn()
|
|
@@ -332,6 +332,9 @@ export interface ResolvedPoppyConfig {
|
|
|
332
332
|
requireRegistration: boolean;
|
|
333
333
|
isRegistered: (clientId: string) => Promise<boolean>;
|
|
334
334
|
isBlocked: (clientId: string) => Promise<boolean>;
|
|
335
|
+
/** As listas do config quando são arrays (o console mostra); `null` = função do host. */
|
|
336
|
+
registeredList: string[] | null;
|
|
337
|
+
blockedList: string[] | null;
|
|
335
338
|
};
|
|
336
339
|
/** Dev/teste: `client_id` http e busca sem guarda de SSRF (ver o input). */
|
|
337
340
|
allowInsecureClientIds: boolean;
|
|
@@ -47,6 +47,11 @@ function membership(value) {
|
|
|
47
47
|
const set = new Set(value ?? []);
|
|
48
48
|
return async (id) => set.has(id);
|
|
49
49
|
}
|
|
50
|
+
function listOf(value) {
|
|
51
|
+
if (typeof value === 'function')
|
|
52
|
+
return null;
|
|
53
|
+
return [...new Set(value ?? [])];
|
|
54
|
+
}
|
|
50
55
|
function typeScopes(input, all, what) {
|
|
51
56
|
if (input === false)
|
|
52
57
|
return null;
|
|
@@ -180,6 +185,8 @@ export function resolvePoppyConfig(input, fallbackScopes) {
|
|
|
180
185
|
requireRegistration: input.clients?.requireRegistration === true,
|
|
181
186
|
isRegistered: membership(input.clients?.registered),
|
|
182
187
|
isBlocked: membership(input.clients?.blocked),
|
|
188
|
+
registeredList: listOf(input.clients?.registered),
|
|
189
|
+
blockedList: listOf(input.clients?.blocked),
|
|
183
190
|
},
|
|
184
191
|
allowInsecureClientIds,
|
|
185
192
|
sessionTokenTtl: positive(input.sessionTokenTtl, 3600, 'sessionTokenTtl'),
|
|
@@ -276,5 +276,11 @@ export declare class PoppyService {
|
|
|
276
276
|
revokeAccountGrant(accountId: string, grantId: string): Promise<boolean>;
|
|
277
277
|
/** Revoga todos os Account Tokens da conta (sair de tudo, conta suspensa…). */
|
|
278
278
|
revokeAllAccountGrants(accountId: string): Promise<number>;
|
|
279
|
+
/**
|
|
280
|
+
* Bloqueio pelo console admin: revoga todos os Account Tokens do agente (todas as
|
|
281
|
+
* contas) e encerra as Sessions dele — os Session Tokens param na próxima request.
|
|
282
|
+
* Devolve quantos Account Tokens estavam vivos.
|
|
283
|
+
*/
|
|
284
|
+
disconnectClient(clientId: string): Promise<number>;
|
|
279
285
|
}
|
|
280
286
|
export type { RequestRow };
|
|
@@ -1277,6 +1277,18 @@ export class PoppyService {
|
|
|
1277
1277
|
await this.revokeGrant(g.id);
|
|
1278
1278
|
return live.length;
|
|
1279
1279
|
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Bloqueio pelo console admin: revoga todos os Account Tokens do agente (todas as
|
|
1282
|
+
* contas) e encerra as Sessions dele — os Session Tokens param na próxima request.
|
|
1283
|
+
* Devolve quantos Account Tokens estavam vivos.
|
|
1284
|
+
*/
|
|
1285
|
+
async disconnectClient(clientId) {
|
|
1286
|
+
const ids = (await this.#grants.unrevokedGrantIdsOfClient(clientId)).filter((id) => id.startsWith('pgrant_'));
|
|
1287
|
+
for (const id of ids)
|
|
1288
|
+
await this.revokeGrant(id);
|
|
1289
|
+
await this.#store.endSessionsOfClient(clientId, this.#now());
|
|
1290
|
+
return ids.length;
|
|
1291
|
+
}
|
|
1280
1292
|
// ─── internos ─────────────────────────────────────────────────────────────
|
|
1281
1293
|
/** Scopes de um pedido de sign-in: obrigatórios, conhecidos e do tipo (§4.4). */
|
|
1282
1294
|
#signInScopes(raw, type) {
|
|
@@ -82,6 +82,8 @@ export declare class PoppyStore {
|
|
|
82
82
|
}): Promise<boolean>;
|
|
83
83
|
/** Sign-out (§4.9): as Sessions logadas com o grant continuam, deslogadas. */
|
|
84
84
|
signOutSessionsOfGrant(grantId: string, at: Date): Promise<number>;
|
|
85
|
+
/** Encerra todas as Sessions de um agente (bloqueio no console): os tokens param na hora. */
|
|
86
|
+
endSessionsOfClient(clientId: string, at: Date): Promise<number>;
|
|
85
87
|
insertToken(row: TokenRow): Promise<void>;
|
|
86
88
|
findToken(hash: string): Promise<TokenRow | null>;
|
|
87
89
|
deleteToken(hash: string): Promise<boolean>;
|
|
@@ -152,6 +152,15 @@ export class PoppyStore {
|
|
|
152
152
|
.update({ grant_id: null, scope: '', updated_at: at });
|
|
153
153
|
return affected(result);
|
|
154
154
|
}
|
|
155
|
+
/** Encerra todas as Sessions de um agente (bloqueio no console): os tokens param na hora. */
|
|
156
|
+
async endSessionsOfClient(clientId, at) {
|
|
157
|
+
const result = await this.conn()
|
|
158
|
+
.from(SESSION_TABLE)
|
|
159
|
+
.where('client_id', clientId)
|
|
160
|
+
.whereNull('ended_at')
|
|
161
|
+
.update({ ended_at: at, grant_id: null, scope: '', updated_at: at });
|
|
162
|
+
return affected(result);
|
|
163
|
+
}
|
|
155
164
|
// ─── session tokens ───────────────────────────────────────────────────────
|
|
156
165
|
async insertToken(row) {
|
|
157
166
|
await this.conn().table(TOKEN_TABLE).insert({
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const REGISTRY_TABLE = "auth_agent_registry";
|
|
2
|
+
export type AgentProtocolId = 'pact' | 'poppy';
|
|
3
|
+
export type AgentRegistryStatus = 'allowed' | 'blocked';
|
|
4
|
+
export interface AgentRegistryRow {
|
|
5
|
+
id: string;
|
|
6
|
+
protocol: AgentProtocolId;
|
|
7
|
+
/** PACT: o `iss` do agente. Poppy: o `client_id` (URL do documento de metadata). */
|
|
8
|
+
identifier: string;
|
|
9
|
+
status: AgentRegistryStatus;
|
|
10
|
+
name: string | null;
|
|
11
|
+
/** JWKS do agente PACT (obrigatório num `allowed` PACT). */
|
|
12
|
+
jwksUri: string | null;
|
|
13
|
+
/** Conta do admin que fez a mudança. */
|
|
14
|
+
updatedBy: string | null;
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
updatedAt: Date;
|
|
17
|
+
}
|
|
18
|
+
/** Quanto tempo o snapshot vale antes de reler a tabela. */
|
|
19
|
+
export declare const SNAPSHOT_TTL_MS: number;
|
|
20
|
+
export declare class AgentRegistryStore {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(conn: () => any, options?: {
|
|
23
|
+
now?: () => number;
|
|
24
|
+
});
|
|
25
|
+
/** Todas as linhas, mais recentes primeiro. Lê do banco (console). */
|
|
26
|
+
list(): Promise<AgentRegistryRow[]>;
|
|
27
|
+
/** A linha do agente, pelo snapshot. `null` = o console não diz nada sobre ele. */
|
|
28
|
+
find(protocol: AgentProtocolId, identifier: string): Promise<AgentRegistryRow | null>;
|
|
29
|
+
findById(id: string): Promise<AgentRegistryRow | null>;
|
|
30
|
+
/** Cria ou troca a linha de (protocolo, identificador). */
|
|
31
|
+
upsert(input: {
|
|
32
|
+
protocol: AgentProtocolId;
|
|
33
|
+
identifier: string;
|
|
34
|
+
status: AgentRegistryStatus;
|
|
35
|
+
name?: string | null;
|
|
36
|
+
jwksUri?: string | null;
|
|
37
|
+
updatedBy?: string | null;
|
|
38
|
+
}): Promise<AgentRegistryRow>;
|
|
39
|
+
delete(id: string): Promise<boolean>;
|
|
40
|
+
invalidate(): void;
|
|
41
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registro de personal agents feito pelo console admin — tabela LIB-OWNED
|
|
3
|
+
* `auth_agent_registry` (ver `schema/ensure.ts`). SOMA ao `defineConfig`:
|
|
4
|
+
*
|
|
5
|
+
* - `allowed`: o agente entra mesmo fora da lista do config (PACT: issuer + JWKS;
|
|
6
|
+
* Poppy: `client_id` registrado quando `clients.requireRegistration`).
|
|
7
|
+
* - `blocked`: o agente é recusado mesmo que o config o liste — bloquear é a
|
|
8
|
+
* ação de emergência, e precisa valer sem deploy.
|
|
9
|
+
*
|
|
10
|
+
* O que o config bloqueia continua bloqueado: o console não desfaz o config.
|
|
11
|
+
*
|
|
12
|
+
* A tabela é pequena e lida em TODA verificação de agente PACT, então as linhas
|
|
13
|
+
* ficam num snapshot em memória por {@link SNAPSHOT_TTL_MS}. Escritas por este
|
|
14
|
+
* processo invalidam na hora; em outras instâncias o efeito chega no próximo
|
|
15
|
+
* refresh — o bloqueio corta os tokens já emitidos pelo banco (grants revogados,
|
|
16
|
+
* Sessions encerradas), então o atraso só vale para um agente começando do zero.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
export const REGISTRY_TABLE = 'auth_agent_registry';
|
|
20
|
+
/** Quanto tempo o snapshot vale antes de reler a tabela. */
|
|
21
|
+
export const SNAPSHOT_TTL_MS = 15 * 1000;
|
|
22
|
+
function toDate(value) {
|
|
23
|
+
if (value instanceof Date)
|
|
24
|
+
return value;
|
|
25
|
+
if (typeof value === 'number')
|
|
26
|
+
return new Date(value);
|
|
27
|
+
if (typeof value === 'string' && /^\d+$/.test(value))
|
|
28
|
+
return new Date(Number(value));
|
|
29
|
+
return new Date(String(value));
|
|
30
|
+
}
|
|
31
|
+
function toRow(row) {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
protocol: row.protocol,
|
|
35
|
+
identifier: row.identifier,
|
|
36
|
+
status: row.status,
|
|
37
|
+
name: row.name ?? null,
|
|
38
|
+
jwksUri: row.jwks_uri ?? null,
|
|
39
|
+
updatedBy: row.updated_by ?? null,
|
|
40
|
+
createdAt: toDate(row.created_at),
|
|
41
|
+
updatedAt: toDate(row.updated_at),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const key = (protocol, identifier) => `${protocol} ${identifier}`;
|
|
45
|
+
export class AgentRegistryStore {
|
|
46
|
+
#conn;
|
|
47
|
+
#now;
|
|
48
|
+
#snapshot = null;
|
|
49
|
+
#loading = null;
|
|
50
|
+
constructor(conn, options = {}) {
|
|
51
|
+
this.#conn = conn;
|
|
52
|
+
this.#now = options.now ?? Date.now;
|
|
53
|
+
}
|
|
54
|
+
/** Todas as linhas, mais recentes primeiro. Lê do banco (console). */
|
|
55
|
+
async list() {
|
|
56
|
+
const rows = await this.#conn().from(REGISTRY_TABLE).orderBy('updated_at', 'desc');
|
|
57
|
+
return rows.map(toRow);
|
|
58
|
+
}
|
|
59
|
+
/** A linha do agente, pelo snapshot. `null` = o console não diz nada sobre ele. */
|
|
60
|
+
async find(protocol, identifier) {
|
|
61
|
+
return (await this.#rows()).get(key(protocol, identifier)) ?? null;
|
|
62
|
+
}
|
|
63
|
+
async findById(id) {
|
|
64
|
+
const row = await this.#conn().from(REGISTRY_TABLE).where('id', id).first();
|
|
65
|
+
return row ? toRow(row) : null;
|
|
66
|
+
}
|
|
67
|
+
/** Cria ou troca a linha de (protocolo, identificador). */
|
|
68
|
+
async upsert(input) {
|
|
69
|
+
const now = new Date(this.#now());
|
|
70
|
+
const existing = await this.#conn()
|
|
71
|
+
.from(REGISTRY_TABLE)
|
|
72
|
+
.where('protocol', input.protocol)
|
|
73
|
+
.where('identifier', input.identifier)
|
|
74
|
+
.first();
|
|
75
|
+
const values = {
|
|
76
|
+
status: input.status,
|
|
77
|
+
name: input.name ?? null,
|
|
78
|
+
jwks_uri: input.jwksUri ?? null,
|
|
79
|
+
updated_by: input.updatedBy ?? null,
|
|
80
|
+
updated_at: now,
|
|
81
|
+
};
|
|
82
|
+
if (existing) {
|
|
83
|
+
await this.#conn().from(REGISTRY_TABLE).where('id', existing.id).update(values);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await this.#conn()
|
|
87
|
+
.table(REGISTRY_TABLE)
|
|
88
|
+
.insert({
|
|
89
|
+
id: `areg_${randomBytes(12).toString('base64url')}`,
|
|
90
|
+
protocol: input.protocol,
|
|
91
|
+
identifier: input.identifier,
|
|
92
|
+
created_at: now,
|
|
93
|
+
...values,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
this.invalidate();
|
|
97
|
+
const row = await this.#conn()
|
|
98
|
+
.from(REGISTRY_TABLE)
|
|
99
|
+
.where('protocol', input.protocol)
|
|
100
|
+
.where('identifier', input.identifier)
|
|
101
|
+
.first();
|
|
102
|
+
return toRow(row);
|
|
103
|
+
}
|
|
104
|
+
async delete(id) {
|
|
105
|
+
const result = await this.#conn().from(REGISTRY_TABLE).where('id', id).delete();
|
|
106
|
+
this.invalidate();
|
|
107
|
+
return (typeof result === 'number' ? result : Array.isArray(result) ? result.length : 0) > 0;
|
|
108
|
+
}
|
|
109
|
+
invalidate() {
|
|
110
|
+
this.#snapshot = null;
|
|
111
|
+
}
|
|
112
|
+
async #rows() {
|
|
113
|
+
const now = this.#now();
|
|
114
|
+
if (this.#snapshot && this.#snapshot.expiresAt > now)
|
|
115
|
+
return this.#snapshot.rows;
|
|
116
|
+
if (!this.#loading) {
|
|
117
|
+
this.#loading = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
const rows = (await this.#conn().from(REGISTRY_TABLE)).map(toRow);
|
|
120
|
+
const map = new Map(rows.map((r) => [key(r.protocol, r.identifier), r]));
|
|
121
|
+
this.#snapshot = { rows: map, expiresAt: this.#now() + SNAPSHOT_TTL_MS };
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Banco fora (ou tabela ainda não criada): fica com o último snapshot, ou
|
|
126
|
+
// nenhum — o config continua valendo sozinho. Tenta de novo logo.
|
|
127
|
+
const rows = this.#snapshot?.rows ?? new Map();
|
|
128
|
+
this.#snapshot = { rows, expiresAt: this.#now() + 1000 };
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
this.#loading = null;
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
}
|
|
136
|
+
return this.#loading;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -3,8 +3,10 @@ import type { OidcService } from '../provider/oidc_service.js';
|
|
|
3
3
|
import { PersonalAgentVerifier } from './agent_identity.js';
|
|
4
4
|
import type { ResolvedPersonalAgentsConfig } from './config.js';
|
|
5
5
|
import { PersonalAgentDelegation } from './delegation_service.js';
|
|
6
|
+
import { DelegationStore, type GrantRow } from './delegation_store.js';
|
|
6
7
|
import type { JsonFetcher } from './poppy/safe_fetch.js';
|
|
7
8
|
import { PoppyService } from './poppy/service.js';
|
|
9
|
+
import { type AgentProtocolId, AgentRegistryStore } from './registry_store.js';
|
|
8
10
|
/** URLs públicas do authorization server de delegação. */
|
|
9
11
|
export interface PersonalAgentUrls {
|
|
10
12
|
/** `issuer` (RFC 8414) — também o `iss` dos tokens de delegação. */
|
|
@@ -35,6 +37,17 @@ export interface PersonalAgentsRuntime {
|
|
|
35
37
|
revokeAgentGrant(accountId: string, grantId: string): Promise<boolean>;
|
|
36
38
|
/** Revoga todos os grants de personal agents da conta. Devolve quantos estavam vivos. */
|
|
37
39
|
revokeAllAgentGrants(accountId: string): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* Registro do console admin (agentes liberados/bloqueados além do config).
|
|
42
|
+
* `null` sem banco (PACT só de identidade sem `lucid.db`).
|
|
43
|
+
*/
|
|
44
|
+
registry: AgentRegistryStore | null;
|
|
45
|
+
/** Grants de todas as contas (console admin). `null` sem banco. */
|
|
46
|
+
grants: DelegationStore | null;
|
|
47
|
+
/** Revoga um grant de QUALQUER conta (console admin). Devolve o grant, ou `null` se já não valia. */
|
|
48
|
+
revokeGrantById(grantId: string): Promise<GrantRow | null>;
|
|
49
|
+
/** Desconecta um agente de todas as contas (bloqueio). Devolve quantos grants estavam vivos. */
|
|
50
|
+
disconnectAgent(protocol: AgentProtocolId, identifier: string): Promise<number>;
|
|
38
51
|
}
|
|
39
52
|
export interface BuildPersonalAgentsOptions {
|
|
40
53
|
/** Resolve serviços do container (redis do replay store). */
|
|
@@ -8,6 +8,7 @@ import { hmacNonceSource } from './poppy/dpop.js';
|
|
|
8
8
|
import { databaseReplayStore, memoryLimiter, memoryReplayStore, redisReplayStore, } from './poppy/replay.js';
|
|
9
9
|
import { PoppyService, poppyUrls } from './poppy/service.js';
|
|
10
10
|
import { PoppyStore } from './poppy/store.js';
|
|
11
|
+
import { AgentRegistryStore } from './registry_store.js';
|
|
11
12
|
import { keystoreSigner } from './signer.js';
|
|
12
13
|
/**
|
|
13
14
|
* Deriva as URLs da ORIGEM do issuer configurado — nunca do Host da request
|
|
@@ -92,6 +93,41 @@ async function resolveReplay(replay, conn, make) {
|
|
|
92
93
|
const redis = await make('redis');
|
|
93
94
|
return redisReplayStore(redis.connection(name));
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Config com o registro do console por cima: um agente BLOQUEADO no console é
|
|
98
|
+
* recusado mesmo que o config o liste; um LIBERADO entra mesmo fora da lista.
|
|
99
|
+
* O que o config bloqueia continua bloqueado.
|
|
100
|
+
*/
|
|
101
|
+
function withRegistry(config, registry) {
|
|
102
|
+
const resolveAgent = async (issuer) => {
|
|
103
|
+
const row = await registry.find('pact', issuer);
|
|
104
|
+
if (row?.status === 'blocked') {
|
|
105
|
+
return { issuer, jwksUri: row.jwksUri ?? '', name: row.name ?? undefined, enabled: false };
|
|
106
|
+
}
|
|
107
|
+
const configured = await config.resolveAgent(issuer);
|
|
108
|
+
if (configured)
|
|
109
|
+
return configured;
|
|
110
|
+
if (row?.status === 'allowed' && row.jwksUri) {
|
|
111
|
+
return { issuer, jwksUri: row.jwksUri, name: row.name ?? undefined };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
const poppy = config.poppy;
|
|
116
|
+
return {
|
|
117
|
+
...config,
|
|
118
|
+
resolveAgent,
|
|
119
|
+
poppy: poppy && {
|
|
120
|
+
...poppy,
|
|
121
|
+
clients: {
|
|
122
|
+
...poppy.clients,
|
|
123
|
+
isBlocked: async (id) => (await poppy.clients.isBlocked(id)) ||
|
|
124
|
+
(await registry.find('poppy', id))?.status === 'blocked',
|
|
125
|
+
isRegistered: async (id) => (await poppy.clients.isRegistered(id)) ||
|
|
126
|
+
(await registry.find('poppy', id))?.status === 'allowed',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
95
131
|
/**
|
|
96
132
|
* Monta (uma vez por `OidcService`) o runtime de personal agents. `null` quando
|
|
97
133
|
* `personalAgents` não está no config — as rotas respondem 404.
|
|
@@ -112,12 +148,25 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
112
148
|
return !(supportsAccountStatus(store) && (await store.isDisabled(accountId)));
|
|
113
149
|
};
|
|
114
150
|
let conn = null;
|
|
151
|
+
const connection = service.config.schema.connection;
|
|
115
152
|
if (config.delegation || config.poppy) {
|
|
116
153
|
const db = await makeDb();
|
|
117
|
-
const connection = service.config.schema.connection;
|
|
118
154
|
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
119
155
|
}
|
|
156
|
+
else {
|
|
157
|
+
// PACT só de identidade não precisa de banco; com ele, o console pode bloquear.
|
|
158
|
+
try {
|
|
159
|
+
const db = await makeDb();
|
|
160
|
+
if (db)
|
|
161
|
+
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
conn = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
120
167
|
const store = conn ? new DelegationStore(conn) : null;
|
|
168
|
+
const registry = conn ? new AgentRegistryStore(conn) : null;
|
|
169
|
+
const effective = registry ? withRegistry(config, registry) : config;
|
|
121
170
|
let delegation = null;
|
|
122
171
|
if (config.delegation && store) {
|
|
123
172
|
delegation = new PersonalAgentDelegation({
|
|
@@ -129,8 +178,8 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
129
178
|
});
|
|
130
179
|
}
|
|
131
180
|
let poppy = null;
|
|
132
|
-
if (
|
|
133
|
-
const cfg =
|
|
181
|
+
if (effective.poppy && store && conn) {
|
|
182
|
+
const cfg = effective.poppy;
|
|
134
183
|
const limiter = cfg.rateLimit === false
|
|
135
184
|
? null
|
|
136
185
|
: typeof cfg.rateLimit === 'function'
|
|
@@ -165,14 +214,47 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
165
214
|
count += await delegation.revokeAllGrants(accountId);
|
|
166
215
|
return count;
|
|
167
216
|
};
|
|
217
|
+
const revokeGrantById = async (grantId) => {
|
|
218
|
+
if (!store)
|
|
219
|
+
return null;
|
|
220
|
+
const grant = await store.findGrant(grantId);
|
|
221
|
+
const now = options.now?.() ?? new Date();
|
|
222
|
+
// Expirado já não vale: revogar não muda nada (e não vira evento de auditoria).
|
|
223
|
+
if (!grant || grant.revokedAt || grant.expiresAt <= now)
|
|
224
|
+
return null;
|
|
225
|
+
if (grantId.startsWith('pgrant_')) {
|
|
226
|
+
if (!poppy)
|
|
227
|
+
return null;
|
|
228
|
+
await poppy.revokeGrant(grantId);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await store.revokeGrantById(grantId, now);
|
|
232
|
+
}
|
|
233
|
+
return grant;
|
|
234
|
+
};
|
|
235
|
+
const disconnectAgent = async (protocol, identifier) => {
|
|
236
|
+
if (!store)
|
|
237
|
+
return 0;
|
|
238
|
+
if (protocol === 'poppy')
|
|
239
|
+
return poppy ? poppy.disconnectClient(identifier) : 0;
|
|
240
|
+
const ids = (await store.unrevokedGrantIdsOfClient(identifier)).filter((id) => !id.startsWith('pgrant_'));
|
|
241
|
+
const at = options.now?.() ?? new Date();
|
|
242
|
+
for (const id of ids)
|
|
243
|
+
await store.revokeGrantById(id, at);
|
|
244
|
+
return ids.length;
|
|
245
|
+
};
|
|
168
246
|
return {
|
|
169
|
-
config,
|
|
247
|
+
config: effective,
|
|
170
248
|
urls,
|
|
171
|
-
verifier: new PersonalAgentVerifier(
|
|
249
|
+
verifier: new PersonalAgentVerifier(effective),
|
|
172
250
|
delegation,
|
|
173
251
|
poppy,
|
|
174
252
|
revokeAgentGrant,
|
|
175
253
|
revokeAllAgentGrants,
|
|
254
|
+
registry,
|
|
255
|
+
grants: store,
|
|
256
|
+
revokeGrantById,
|
|
257
|
+
disconnectAgent,
|
|
176
258
|
};
|
|
177
259
|
})();
|
|
178
260
|
// Falha (DB indisponível) não fica em cache — a próxima request tenta de novo.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
|
|
9
9
|
* emitido não está aqui.
|
|
10
10
|
*/
|
|
11
|
-
export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked'];
|
|
11
|
+
export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked', 'agent.grant_revoked_by_admin', 'agent.allowed', 'agent.blocked', 'agent.registry_removed'];
|
|
12
12
|
/** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
|
|
13
13
|
export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
|
|
14
14
|
/**
|
|
@@ -113,4 +113,10 @@ export const AUDIT_EVENT_TYPES = [
|
|
|
113
113
|
'agent.delegation_approved',
|
|
114
114
|
'agent.delegation_denied',
|
|
115
115
|
'agent.grant_revoked',
|
|
116
|
+
// Console admin: um admin revogou a conexão de um usuário, ou liberou/bloqueou um
|
|
117
|
+
// agente (PACT pelo `iss`, Poppy pelo `client_id`).
|
|
118
|
+
'agent.grant_revoked_by_admin',
|
|
119
|
+
'agent.allowed',
|
|
120
|
+
'agent.blocked',
|
|
121
|
+
'agent.registry_removed',
|
|
116
122
|
];
|
|
@@ -49,7 +49,7 @@ export declare function grantDto(grant: AdminGrant): {
|
|
|
49
49
|
};
|
|
50
50
|
export declare function auditDto(event: StoredAuditEvent): {
|
|
51
51
|
id: string;
|
|
52
|
-
type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
|
|
52
|
+
type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.allowed" | "agent.blocked" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "agent.grant_revoked_by_admin" | "agent.registry_removed" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
|
|
53
53
|
accountId: string | null;
|
|
54
54
|
email: string | null;
|
|
55
55
|
clientId: string | null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
|
+
import { accountPath } from '../account_paths.js';
|
|
3
4
|
import { resolveAccountRoles } from '../account_roles.js';
|
|
4
5
|
import { getAdminPrefix } from '../admin_prefix.js';
|
|
5
6
|
// ─── Shell HTML cache ─────────────────────────────────────────────────────────
|
|
@@ -134,6 +135,12 @@ export default class AdminShellController {
|
|
|
134
135
|
currentUser,
|
|
135
136
|
endpoints: {
|
|
136
137
|
api: `${adminBase}/api`,
|
|
138
|
+
// Confirmação de identidade (sudo) das ações destrutivas.
|
|
139
|
+
confirm: accountPath('confirm'),
|
|
140
|
+
},
|
|
141
|
+
features: {
|
|
142
|
+
// A página de personal agents só aparece com `personalAgents` no config.
|
|
143
|
+
personalAgents: !!cfg.personalAgents,
|
|
137
144
|
},
|
|
138
145
|
};
|
|
139
146
|
const { html: rawHtml } = await loadShell();
|