@adonis-agora/authkit-server 0.69.0 → 0.71.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/commands/commands.json +18 -0
- package/build/commands/normalize_emails.d.ts +21 -0
- package/build/commands/normalize_emails.js +106 -0
- package/build/index.d.ts +4 -3
- package/build/index.js +6 -2
- package/build/src/accounts/account_store.d.ts +65 -1
- package/build/src/accounts/account_store.js +24 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +16 -0
- package/build/src/accounts/lucid_store/mfa.js +22 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/commands/import_users.js +7 -2
- package/build/src/commands/normalize_emails.d.ts +78 -0
- package/build/src/commands/normalize_emails.js +129 -0
- package/build/src/host/account_api/account_api_controller.d.ts +2 -0
- package/build/src/host/account_api/account_api_controller.js +22 -3
- package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
- package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
- package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
- package/build/src/host/account_lockout.js +7 -2
- package/build/src/host/admin_api/admin_users_service.js +11 -3
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_validators.d.ts +2 -2
- package/build/src/host/admin_validators.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +1 -2
- package/build/src/host/controllers/account_orgs_controller.js +4 -18
- package/build/src/host/controllers/account_security_controller.js +3 -1
- package/build/src/host/controllers/account_session_controller.js +7 -5
- package/build/src/host/controllers/interaction_controller.d.ts +10 -0
- package/build/src/host/controllers/interaction_controller.js +46 -14
- package/build/src/host/controllers/registration_controller.js +15 -5
- package/build/src/host/controllers/social_controller.js +8 -1
- package/build/src/host/email_identifier.d.ts +29 -0
- package/build/src/host/email_identifier.js +31 -0
- package/build/src/host/org_policy.d.ts +26 -0
- package/build/src/host/org_policy.js +35 -0
- package/build/src/host/passkey_registration_challenge.d.ts +12 -0
- package/build/src/host/passkey_registration_challenge.js +12 -0
- package/build/src/host/register_auth_host.js +51 -0
- package/build/src/host/sudo_mode.d.ts +17 -0
- package/build/src/host/sudo_mode.js +31 -12
- package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/host/validators.d.ts +5 -5
- package/build/src/host/validators.js +17 -5
- package/package.json +2 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account Self-Service JSON API — ESCRITA de organizações
|
|
3
|
+
*
|
|
4
|
+
* Espelho JSON dos POSTs de formulário do `AccountOrgsController`, para hosts
|
|
5
|
+
* que desenham as próprias telas de organização dentro do shell do produto e
|
|
6
|
+
* não querem mandar o usuário ao console `/account/orgs`.
|
|
7
|
+
*
|
|
8
|
+
* Mapa de rotas (todas sob o `accountGuard`; as mutantes sob o CSRF do shield
|
|
9
|
+
* do host — nenhuma delas entra em `authkitCsrfExceptions`):
|
|
10
|
+
* POST /account/api/orgs → criar org (allowSelfCreate)
|
|
11
|
+
* POST /account/api/orgs/deactivate → limpar a org ativa
|
|
12
|
+
* POST /account/api/orgs/invitations/:token/accept → aceitar convite
|
|
13
|
+
* POST /account/api/orgs/:id/activate → definir org ativa
|
|
14
|
+
* POST /account/api/orgs/:id/leave → sair da org
|
|
15
|
+
* POST /account/api/orgs/:id/invitations → convidar por e-mail
|
|
16
|
+
* DELETE /account/api/orgs/:id/invitations/:invId → revogar convite
|
|
17
|
+
* PATCH /account/api/orgs/:id/members/:accountId → trocar papel
|
|
18
|
+
* DELETE /account/api/orgs/:id/members/:accountId → remover membro
|
|
19
|
+
*
|
|
20
|
+
* O que este controller NÃO afrouxa em relação ao formulário:
|
|
21
|
+
*
|
|
22
|
+
* - **Escopo por conta.** O ator é sempre `session[ACCOUNT_SESSION_KEY]`;
|
|
23
|
+
* nenhum handler aceita um id de ator vindo do corpo.
|
|
24
|
+
* - **Papel na org.** Convidar, revogar convite, remover membro e trocar
|
|
25
|
+
* papel exigem membership `owner`/`admin` NA ORG DO PATH — a mesma checagem
|
|
26
|
+
* do form, que é o que impede o IDOR cross-org.
|
|
27
|
+
* - **Escalonamento.** Só um `owner` concede o papel `owner`; um admin
|
|
28
|
+
* tentando isso leva 403, igual ao form.
|
|
29
|
+
* - **Catálogo de papéis.** O papel passa pelo `isRoleInCatalog` (runtime →
|
|
30
|
+
* config → defaults), o MESMO helper puro do form e do caminho admin.
|
|
31
|
+
*
|
|
32
|
+
* O que muda de propósito: a resposta. Onde o form redireciona para
|
|
33
|
+
* `/account/orgs` (com ou sem flash), aqui sai JSON — `{ error: { code,
|
|
34
|
+
* message } }` com o status certo, para a tela do host poder reagir.
|
|
35
|
+
*
|
|
36
|
+
* Sudo: o console HTML NÃO exige sudo em nenhuma operação de org, e este
|
|
37
|
+
* espelho segue igual. Exigir aqui o que o form não exige seria divergência na
|
|
38
|
+
* outra direção — e a decisão de qual superfície é sensível pertence a uma
|
|
39
|
+
* mudança de política, não a um espelho de formato.
|
|
40
|
+
*
|
|
41
|
+
* Política EFETIVA, não o config estático. `allowSelfCreate`, o catálogo de
|
|
42
|
+
* papéis e o TTL do convite saem do MESMO módulo que o console HTML usa
|
|
43
|
+
* (`host/org_policy.ts`: setting da org → setting global → config → default da
|
|
44
|
+
* lib). Ler só o config estático daria uma superfície que diverge da outra na
|
|
45
|
+
* primeira vez que um admin mexesse na setting — e divergência entre o form e
|
|
46
|
+
* o espelho é exatamente o bug que este controller não pode ter.
|
|
47
|
+
*/
|
|
48
|
+
import '../augmentations.js';
|
|
49
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
50
|
+
export default class AccountOrgsApiController {
|
|
51
|
+
#private;
|
|
52
|
+
/** Criar uma org. Exige `allowSelfCreate` na política efetiva. */
|
|
53
|
+
createOrg(ctx: HttpContext): Promise<void | {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
slug: string;
|
|
57
|
+
logoUrl: string | null;
|
|
58
|
+
role: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Define a org ativa (cookie `authkit_active_org`). Valida membership. */
|
|
61
|
+
activateOrg(ctx: HttpContext): Promise<void | {
|
|
62
|
+
ok: boolean;
|
|
63
|
+
activeOrgId: string;
|
|
64
|
+
slug: any;
|
|
65
|
+
role: any;
|
|
66
|
+
}>;
|
|
67
|
+
/** Limpa a org ativa. Não depende de membership (só apaga o cookie). */
|
|
68
|
+
deactivateOrg(ctx: HttpContext): Promise<{
|
|
69
|
+
ok: boolean;
|
|
70
|
+
activeOrgId: null;
|
|
71
|
+
}>;
|
|
72
|
+
/** Sai da org. O store recusa o último owner (`reason: 'last_owner'`). */
|
|
73
|
+
leaveOrg(ctx: HttpContext): Promise<void | {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
orgId: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** Convida alguém por e-mail. Exige owner/admin; papel validado no catálogo. */
|
|
78
|
+
inviteMember(ctx: HttpContext): Promise<void | {
|
|
79
|
+
id: any;
|
|
80
|
+
organizationId: string;
|
|
81
|
+
email: any;
|
|
82
|
+
role: string;
|
|
83
|
+
expiresAt: any;
|
|
84
|
+
createdAt: any;
|
|
85
|
+
}>;
|
|
86
|
+
/** Revoga um convite pendente. Escopado por org (anti-IDOR), como o form. */
|
|
87
|
+
revokeInvitation(ctx: HttpContext): Promise<void | {
|
|
88
|
+
ok: boolean;
|
|
89
|
+
revoked: string;
|
|
90
|
+
}>;
|
|
91
|
+
/** Remove um membro. Exige owner/admin na org. */
|
|
92
|
+
removeMember(ctx: HttpContext): Promise<void | {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
orgId: string;
|
|
95
|
+
accountId: string;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Troca o papel de um membro.
|
|
99
|
+
*
|
|
100
|
+
* Não existe equivalente member-facing em formulário (o console HTML só
|
|
101
|
+
* troca papel pelo caminho ADMIN). As regras vieram, então, da interseção
|
|
102
|
+
* das duas superfícies que já existem: o guard de owner/admin + catálogo de
|
|
103
|
+
* papéis do `invite` member-facing, e a checagem de `last_owner` do
|
|
104
|
+
* `AdminOrgsService.updateMemberRole`. Conceder `owner` continua privativo
|
|
105
|
+
* de um owner.
|
|
106
|
+
*/
|
|
107
|
+
updateMemberRole(ctx: HttpContext): Promise<void | {
|
|
108
|
+
ok: boolean;
|
|
109
|
+
orgId: string;
|
|
110
|
+
accountId: string;
|
|
111
|
+
role: string;
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Aceita um convite pelo token do e-mail.
|
|
115
|
+
*
|
|
116
|
+
* Diferente do form (montado FORA do guard para tratar o não-autenticado com
|
|
117
|
+
* um redirect para o login), esta rota vive DENTRO do `accountGuard`: uma
|
|
118
|
+
* tela SPA já está logada, e a resposta a um visitante anônimo aqui teria de
|
|
119
|
+
* ser 401 em JSON, não uma navegação.
|
|
120
|
+
*/
|
|
121
|
+
acceptInvitation(ctx: HttpContext): Promise<void | {
|
|
122
|
+
ok: boolean;
|
|
123
|
+
organizationId: any;
|
|
124
|
+
role: any;
|
|
125
|
+
}>;
|
|
126
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account Self-Service JSON API — ESCRITA de organizações
|
|
3
|
+
*
|
|
4
|
+
* Espelho JSON dos POSTs de formulário do `AccountOrgsController`, para hosts
|
|
5
|
+
* que desenham as próprias telas de organização dentro do shell do produto e
|
|
6
|
+
* não querem mandar o usuário ao console `/account/orgs`.
|
|
7
|
+
*
|
|
8
|
+
* Mapa de rotas (todas sob o `accountGuard`; as mutantes sob o CSRF do shield
|
|
9
|
+
* do host — nenhuma delas entra em `authkitCsrfExceptions`):
|
|
10
|
+
* POST /account/api/orgs → criar org (allowSelfCreate)
|
|
11
|
+
* POST /account/api/orgs/deactivate → limpar a org ativa
|
|
12
|
+
* POST /account/api/orgs/invitations/:token/accept → aceitar convite
|
|
13
|
+
* POST /account/api/orgs/:id/activate → definir org ativa
|
|
14
|
+
* POST /account/api/orgs/:id/leave → sair da org
|
|
15
|
+
* POST /account/api/orgs/:id/invitations → convidar por e-mail
|
|
16
|
+
* DELETE /account/api/orgs/:id/invitations/:invId → revogar convite
|
|
17
|
+
* PATCH /account/api/orgs/:id/members/:accountId → trocar papel
|
|
18
|
+
* DELETE /account/api/orgs/:id/members/:accountId → remover membro
|
|
19
|
+
*
|
|
20
|
+
* O que este controller NÃO afrouxa em relação ao formulário:
|
|
21
|
+
*
|
|
22
|
+
* - **Escopo por conta.** O ator é sempre `session[ACCOUNT_SESSION_KEY]`;
|
|
23
|
+
* nenhum handler aceita um id de ator vindo do corpo.
|
|
24
|
+
* - **Papel na org.** Convidar, revogar convite, remover membro e trocar
|
|
25
|
+
* papel exigem membership `owner`/`admin` NA ORG DO PATH — a mesma checagem
|
|
26
|
+
* do form, que é o que impede o IDOR cross-org.
|
|
27
|
+
* - **Escalonamento.** Só um `owner` concede o papel `owner`; um admin
|
|
28
|
+
* tentando isso leva 403, igual ao form.
|
|
29
|
+
* - **Catálogo de papéis.** O papel passa pelo `isRoleInCatalog` (runtime →
|
|
30
|
+
* config → defaults), o MESMO helper puro do form e do caminho admin.
|
|
31
|
+
*
|
|
32
|
+
* O que muda de propósito: a resposta. Onde o form redireciona para
|
|
33
|
+
* `/account/orgs` (com ou sem flash), aqui sai JSON — `{ error: { code,
|
|
34
|
+
* message } }` com o status certo, para a tela do host poder reagir.
|
|
35
|
+
*
|
|
36
|
+
* Sudo: o console HTML NÃO exige sudo em nenhuma operação de org, e este
|
|
37
|
+
* espelho segue igual. Exigir aqui o que o form não exige seria divergência na
|
|
38
|
+
* outra direção — e a decisão de qual superfície é sensível pertence a uma
|
|
39
|
+
* mudança de política, não a um espelho de formato.
|
|
40
|
+
*
|
|
41
|
+
* Política EFETIVA, não o config estático. `allowSelfCreate`, o catálogo de
|
|
42
|
+
* papéis e o TTL do convite saem do MESMO módulo que o console HTML usa
|
|
43
|
+
* (`host/org_policy.ts`: setting da org → setting global → config → default da
|
|
44
|
+
* lib). Ler só o config estático daria uma superfície que diverge da outra na
|
|
45
|
+
* primeira vez que um admin mexesse na setting — e divergência entre o form e
|
|
46
|
+
* o espelho é exatamente o bug que este controller não pode ter.
|
|
47
|
+
*/
|
|
48
|
+
import { createHash } from 'node:crypto';
|
|
49
|
+
import '../augmentations.js';
|
|
50
|
+
import { supportsOrganizations } from '../../accounts/account_store.js';
|
|
51
|
+
import { accountPath } from '../account_paths.js';
|
|
52
|
+
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
53
|
+
import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
|
|
54
|
+
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
55
|
+
// Política efetiva: o MESMO módulo que o console HTML usa. Duas cópias da
|
|
56
|
+
// resolução são como o espelho JSON acabaria mais frouxo que o formulário.
|
|
57
|
+
import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
|
|
58
|
+
import { authkitOrigin } from '../origin.js';
|
|
59
|
+
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
60
|
+
import { isRoleInCatalog } from '../runtime_toggles.js';
|
|
61
|
+
/** Erro JSON padrão — mesmo envelope do `account_api_controller`. */
|
|
62
|
+
function apiErr(code, message) {
|
|
63
|
+
return { error: { code, message } };
|
|
64
|
+
}
|
|
65
|
+
/** Papéis que podem administrar uma org no fluxo member-facing. */
|
|
66
|
+
const MANAGER_ROLES = new Set(['owner', 'admin']);
|
|
67
|
+
/**
|
|
68
|
+
* O erro é uma violação de UNICIDADE do banco (slug já existe), e não uma falha
|
|
69
|
+
* de infraestrutura?
|
|
70
|
+
*
|
|
71
|
+
* A distinção importa: mapear TODA exceção do `createOrg` para `409 slug_taken`
|
|
72
|
+
* — como o form e o caminho admin fazem, porque lá o desfecho é um redirect sem
|
|
73
|
+
* mensagem — diria "escolha outro slug" para um banco fora do ar. O host
|
|
74
|
+
* tentaria slug após slug sem nunca conseguir, e o 409 esconderia a
|
|
75
|
+
* indisponibilidade do monitoramento. Aqui, só a violação reconhecida vira 409;
|
|
76
|
+
* qualquer outra coisa sobe e o handler de erro do app responde 500.
|
|
77
|
+
*
|
|
78
|
+
* Os códigos cobrem os drivers que o Lucid suporta. Um store customizado que
|
|
79
|
+
* lance outra coisa cai no caminho "não reconhecido" — que é o conservador: um
|
|
80
|
+
* 500 espúrio é ruído, um 409 espúrio é uma mentira sobre o estado do sistema.
|
|
81
|
+
*/
|
|
82
|
+
function isUniqueViolation(err) {
|
|
83
|
+
const code = err?.code;
|
|
84
|
+
if (typeof code === 'string') {
|
|
85
|
+
// pg: unique_violation. mysql/mariadb: ER_DUP_ENTRY. sqlite: os códigos
|
|
86
|
+
// ESTENDIDOS da família de unicidade — e só eles. `SQLITE_CONSTRAINT` cru
|
|
87
|
+
// cobre também NOTNULL, CHECK e FOREIGNKEY, que não são "slug repetido".
|
|
88
|
+
if (code === '23505' ||
|
|
89
|
+
code === 'ER_DUP_ENTRY' ||
|
|
90
|
+
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
|
91
|
+
code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
// Drivers sqlite antigos reportam só o código genérico e põem a natureza da
|
|
95
|
+
// violação na mensagem. Checar a mensagem é frágil, então vale apenas
|
|
96
|
+
// ESTREITADO a esse caso: sem ele, um sqlite legado devolveria 500 na
|
|
97
|
+
// corrida; com ele, nenhum outro driver muda de comportamento.
|
|
98
|
+
if (code === 'SQLITE_CONSTRAINT') {
|
|
99
|
+
const message = err?.message;
|
|
100
|
+
return typeof message === 'string' && /UNIQUE constraint failed/i.test(message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const errno = err?.errno;
|
|
104
|
+
// mysql/mariadb (1062) e mssql (2627 unique constraint, 2601 unique index).
|
|
105
|
+
return errno === 1062 || errno === 2627 || errno === 2601;
|
|
106
|
+
}
|
|
107
|
+
export default class AccountOrgsApiController {
|
|
108
|
+
// ─── POST /account/api/orgs ──────────────────────────────────────────────
|
|
109
|
+
/** Criar uma org. Exige `allowSelfCreate` na política efetiva. */
|
|
110
|
+
async createOrg(ctx) {
|
|
111
|
+
const c = await this.#context(ctx);
|
|
112
|
+
if (!c)
|
|
113
|
+
return;
|
|
114
|
+
const policy = await effectiveOrgPolicy(ctx, c.cfg);
|
|
115
|
+
if (!policy.allowSelfCreate) {
|
|
116
|
+
return ctx.response.forbidden(apiErr('self_create_disabled', 'Organization self-service creation is off.'));
|
|
117
|
+
}
|
|
118
|
+
const name = String(ctx.request.input('name', '') ?? '').trim();
|
|
119
|
+
const slug = String(ctx.request.input('slug', '') ?? '').trim();
|
|
120
|
+
if (!name || !slug) {
|
|
121
|
+
return ctx.response.badRequest(apiErr('invalid_input', 'Both `name` and `slug` are required.'));
|
|
122
|
+
}
|
|
123
|
+
// Pré-checagem determinística do slug: dá o 409 pelo caminho normal, sem
|
|
124
|
+
// depender de reconhecer o erro do driver. O form engole a falha e
|
|
125
|
+
// redireciona; aqui a tela do host precisa poder dizer "esse slug já
|
|
126
|
+
// existe" em vez de recarregar sem explicação.
|
|
127
|
+
if (await c.store.findOrgBySlug(slug)) {
|
|
128
|
+
return ctx.response.conflict(apiErr('slug_taken', 'Slug already in use.'));
|
|
129
|
+
}
|
|
130
|
+
let org;
|
|
131
|
+
try {
|
|
132
|
+
org = await c.store.createOrg({ name, slug, ownerAccountId: c.accountId });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Corrida entre a pré-checagem e o insert: o índice único é quem decide.
|
|
136
|
+
if (isUniqueViolation(err)) {
|
|
137
|
+
return ctx.response.conflict(apiErr('slug_taken', 'Slug already in use.'));
|
|
138
|
+
}
|
|
139
|
+
// Banco fora do ar, timeout, constraint de outra natureza: NÃO é "escolha
|
|
140
|
+
// outro slug". Sobe para o handler de erro do app (500).
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
await c.cfg.audit?.record({
|
|
144
|
+
type: 'organization.created',
|
|
145
|
+
accountId: c.accountId,
|
|
146
|
+
orgId: org.id,
|
|
147
|
+
metadata: { slug },
|
|
148
|
+
});
|
|
149
|
+
ctx.response.status(201);
|
|
150
|
+
return {
|
|
151
|
+
id: org.id,
|
|
152
|
+
name: org.name,
|
|
153
|
+
slug: org.slug,
|
|
154
|
+
logoUrl: org.logoUrl ?? null,
|
|
155
|
+
role: 'owner',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ─── POST /account/api/orgs/:id/activate ────────────────────────────────
|
|
159
|
+
/** Define a org ativa (cookie `authkit_active_org`). Valida membership. */
|
|
160
|
+
async activateOrg(ctx) {
|
|
161
|
+
const c = await this.#context(ctx);
|
|
162
|
+
if (!c)
|
|
163
|
+
return;
|
|
164
|
+
const orgId = String(ctx.request.param('id'));
|
|
165
|
+
const membership = await c.store.getOrgMembership(orgId, c.accountId);
|
|
166
|
+
// Não-membro e org inexistente respondem IGUAL (404 sem detalhe): distinguir
|
|
167
|
+
// as duas vazaria a existência de orgs de terceiros.
|
|
168
|
+
if (!membership) {
|
|
169
|
+
return ctx.response.notFound(apiErr('not_found', 'Organization not found or not a member.'));
|
|
170
|
+
}
|
|
171
|
+
const org = await c.store.findOrgById(orgId);
|
|
172
|
+
if (!org) {
|
|
173
|
+
return ctx.response.notFound(apiErr('not_found', 'Organization not found or not a member.'));
|
|
174
|
+
}
|
|
175
|
+
ctx.response.cookie(ACTIVE_ORG_COOKIE, encodeActiveOrgCookie({ orgId, orgSlug: org.slug, orgRole: membership.role }), {
|
|
176
|
+
httpOnly: true,
|
|
177
|
+
sameSite: 'lax',
|
|
178
|
+
maxAge: ACTIVE_ORG_COOKIE_TTL,
|
|
179
|
+
secure: ctx.request.secure(),
|
|
180
|
+
path: '/',
|
|
181
|
+
});
|
|
182
|
+
await c.cfg.audit?.record({
|
|
183
|
+
type: 'organization.switched',
|
|
184
|
+
accountId: c.accountId,
|
|
185
|
+
orgId,
|
|
186
|
+
metadata: { orgId, orgSlug: org.slug },
|
|
187
|
+
});
|
|
188
|
+
return { ok: true, activeOrgId: orgId, slug: org.slug, role: membership.role };
|
|
189
|
+
}
|
|
190
|
+
// ─── POST /account/api/orgs/deactivate ──────────────────────────────────
|
|
191
|
+
/** Limpa a org ativa. Não depende de membership (só apaga o cookie). */
|
|
192
|
+
async deactivateOrg(ctx) {
|
|
193
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
194
|
+
const cfg = service.config;
|
|
195
|
+
const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
196
|
+
ctx.response.clearCookie(ACTIVE_ORG_COOKIE, { path: '/' });
|
|
197
|
+
await cfg.audit?.record({ type: 'organization.deactivated', accountId });
|
|
198
|
+
return { ok: true, activeOrgId: null };
|
|
199
|
+
}
|
|
200
|
+
// ─── POST /account/api/orgs/:id/leave ───────────────────────────────────
|
|
201
|
+
/** Sai da org. O store recusa o último owner (`reason: 'last_owner'`). */
|
|
202
|
+
async leaveOrg(ctx) {
|
|
203
|
+
const c = await this.#context(ctx);
|
|
204
|
+
if (!c)
|
|
205
|
+
return;
|
|
206
|
+
const orgId = String(ctx.request.param('id'));
|
|
207
|
+
const result = await c.store.removeOrgMember(orgId, c.accountId);
|
|
208
|
+
if (!result.ok) {
|
|
209
|
+
if (result.reason === 'last_owner') {
|
|
210
|
+
return ctx.response
|
|
211
|
+
.status(409)
|
|
212
|
+
.send(apiErr('last_owner', 'The last owner cannot leave the organization.'));
|
|
213
|
+
}
|
|
214
|
+
return ctx.response.notFound(apiErr('not_found', 'Membership not found.'));
|
|
215
|
+
}
|
|
216
|
+
await c.cfg.audit?.record({
|
|
217
|
+
type: 'organization.member_removed',
|
|
218
|
+
accountId: c.accountId,
|
|
219
|
+
orgId,
|
|
220
|
+
metadata: { orgId, self: true },
|
|
221
|
+
});
|
|
222
|
+
return { ok: true, orgId };
|
|
223
|
+
}
|
|
224
|
+
// ─── POST /account/api/orgs/:id/invitations ─────────────────────────────
|
|
225
|
+
/** Convida alguém por e-mail. Exige owner/admin; papel validado no catálogo. */
|
|
226
|
+
async inviteMember(ctx) {
|
|
227
|
+
const c = await this.#context(ctx);
|
|
228
|
+
if (!c)
|
|
229
|
+
return;
|
|
230
|
+
const orgId = String(ctx.request.param('id'));
|
|
231
|
+
const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
|
|
232
|
+
if (!membership)
|
|
233
|
+
return;
|
|
234
|
+
const email = String(ctx.request.input('email', '') ?? '').trim();
|
|
235
|
+
const role = String(ctx.request.input('role', 'member') ?? 'member').trim();
|
|
236
|
+
if (!email) {
|
|
237
|
+
return ctx.response.badRequest(apiErr('invalid_input', '`email` is required.'));
|
|
238
|
+
}
|
|
239
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
240
|
+
if (!(await isRoleInCatalog(role, settings, orgPolicyDefaults(c.cfg), orgId))) {
|
|
241
|
+
return ctx.response.unprocessableEntity(apiErr('invalid_role', 'Role inválida.'));
|
|
242
|
+
}
|
|
243
|
+
// Só um OWNER concede `owner` — um admin tentando isso é escalonamento.
|
|
244
|
+
if (role === 'owner' && membership.role !== 'owner') {
|
|
245
|
+
return ctx.response.forbidden(apiErr('forbidden', 'Only an owner can grant the owner role.'));
|
|
246
|
+
}
|
|
247
|
+
const policy = await effectiveOrgPolicy(ctx, c.cfg, orgId);
|
|
248
|
+
const { invitation, token } = await c.store.createOrgInvitation({
|
|
249
|
+
organizationId: orgId,
|
|
250
|
+
email,
|
|
251
|
+
role,
|
|
252
|
+
invitedBy: c.accountId,
|
|
253
|
+
ttlHours: policy.invitationTtlHours,
|
|
254
|
+
});
|
|
255
|
+
// Entrega best-effort, igual ao form: um e-mail que não sai NÃO desfaz o
|
|
256
|
+
// convite (ele continua aceitável pela lista de convites do convidado).
|
|
257
|
+
try {
|
|
258
|
+
const org = await c.store.findOrgById(orgId);
|
|
259
|
+
const acceptUrl = `${authkitOrigin(c.cfg)}${accountPath('orgs')}/invitations/${token}/accept`;
|
|
260
|
+
const payload = {
|
|
261
|
+
email,
|
|
262
|
+
invitationId: invitation.id,
|
|
263
|
+
orgName: org?.name ?? orgId,
|
|
264
|
+
orgSlug: org?.slug ?? orgId,
|
|
265
|
+
role,
|
|
266
|
+
acceptUrl,
|
|
267
|
+
token,
|
|
268
|
+
};
|
|
269
|
+
if (c.cfg.mail?.onOrgInvitation)
|
|
270
|
+
await c.cfg.mail.onOrgInvitation(payload);
|
|
271
|
+
else
|
|
272
|
+
await sendOrgInvitationEmail(ctx, payload);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* best-effort */
|
|
276
|
+
}
|
|
277
|
+
await c.cfg.audit?.record({
|
|
278
|
+
type: 'organization.invitation_sent',
|
|
279
|
+
accountId: c.accountId,
|
|
280
|
+
orgId,
|
|
281
|
+
metadata: { orgId, email, role },
|
|
282
|
+
});
|
|
283
|
+
ctx.response.status(201);
|
|
284
|
+
return {
|
|
285
|
+
id: invitation.id,
|
|
286
|
+
organizationId: orgId,
|
|
287
|
+
email: invitation.email ?? email,
|
|
288
|
+
role,
|
|
289
|
+
expiresAt: invitation.expiresAt ?? null,
|
|
290
|
+
createdAt: invitation.createdAt ?? null,
|
|
291
|
+
// O TOKEN NÃO VOLTA. Ele é a credencial de aceite e viaja por e-mail; um
|
|
292
|
+
// admin que o lesse na resposta entraria na org como o convidado.
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// ─── DELETE /account/api/orgs/:id/invitations/:invId ────────────────────
|
|
296
|
+
/** Revoga um convite pendente. Escopado por org (anti-IDOR), como o form. */
|
|
297
|
+
async revokeInvitation(ctx) {
|
|
298
|
+
const c = await this.#context(ctx);
|
|
299
|
+
if (!c)
|
|
300
|
+
return;
|
|
301
|
+
const orgId = String(ctx.request.param('id'));
|
|
302
|
+
const invId = String(ctx.request.param('invId'));
|
|
303
|
+
const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
|
|
304
|
+
if (!membership)
|
|
305
|
+
return;
|
|
306
|
+
const revoked = await c.store.revokeInvitation(orgId, invId);
|
|
307
|
+
if (!revoked) {
|
|
308
|
+
return ctx.response.notFound(apiErr('not_found', 'Invitation not found in this organization.'));
|
|
309
|
+
}
|
|
310
|
+
await c.cfg.audit?.record({
|
|
311
|
+
type: 'organization.invitation_revoked',
|
|
312
|
+
actorId: c.accountId,
|
|
313
|
+
orgId,
|
|
314
|
+
metadata: { orgId, invitationId: invId },
|
|
315
|
+
});
|
|
316
|
+
return { ok: true, revoked: invId };
|
|
317
|
+
}
|
|
318
|
+
// ─── DELETE /account/api/orgs/:id/members/:accountId ────────────────────
|
|
319
|
+
/** Remove um membro. Exige owner/admin na org. */
|
|
320
|
+
async removeMember(ctx) {
|
|
321
|
+
const c = await this.#context(ctx);
|
|
322
|
+
if (!c)
|
|
323
|
+
return;
|
|
324
|
+
const orgId = String(ctx.request.param('id'));
|
|
325
|
+
const targetId = String(ctx.request.param('accountId'));
|
|
326
|
+
const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
|
|
327
|
+
if (!membership)
|
|
328
|
+
return;
|
|
329
|
+
const result = await c.store.removeOrgMember(orgId, targetId);
|
|
330
|
+
if (!result.ok) {
|
|
331
|
+
if (result.reason === 'last_owner') {
|
|
332
|
+
return ctx.response
|
|
333
|
+
.status(409)
|
|
334
|
+
.send(apiErr('last_owner', 'The last owner cannot be removed.'));
|
|
335
|
+
}
|
|
336
|
+
return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
|
|
337
|
+
}
|
|
338
|
+
await c.cfg.audit?.record({
|
|
339
|
+
type: 'organization.member_removed',
|
|
340
|
+
actorId: c.accountId,
|
|
341
|
+
orgId,
|
|
342
|
+
metadata: { orgId, targetAccountId: targetId },
|
|
343
|
+
});
|
|
344
|
+
return { ok: true, orgId, accountId: targetId };
|
|
345
|
+
}
|
|
346
|
+
// ─── PATCH /account/api/orgs/:id/members/:accountId ─────────────────────
|
|
347
|
+
/**
|
|
348
|
+
* Troca o papel de um membro.
|
|
349
|
+
*
|
|
350
|
+
* Não existe equivalente member-facing em formulário (o console HTML só
|
|
351
|
+
* troca papel pelo caminho ADMIN). As regras vieram, então, da interseção
|
|
352
|
+
* das duas superfícies que já existem: o guard de owner/admin + catálogo de
|
|
353
|
+
* papéis do `invite` member-facing, e a checagem de `last_owner` do
|
|
354
|
+
* `AdminOrgsService.updateMemberRole`. Conceder `owner` continua privativo
|
|
355
|
+
* de um owner.
|
|
356
|
+
*/
|
|
357
|
+
async updateMemberRole(ctx) {
|
|
358
|
+
const c = await this.#context(ctx);
|
|
359
|
+
if (!c)
|
|
360
|
+
return;
|
|
361
|
+
const orgId = String(ctx.request.param('id'));
|
|
362
|
+
const targetId = String(ctx.request.param('accountId'));
|
|
363
|
+
const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
|
|
364
|
+
if (!membership)
|
|
365
|
+
return;
|
|
366
|
+
const role = String(ctx.request.input('role', '') ?? '').trim();
|
|
367
|
+
if (!role) {
|
|
368
|
+
return ctx.response.badRequest(apiErr('invalid_input', '`role` is required.'));
|
|
369
|
+
}
|
|
370
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
371
|
+
if (!(await isRoleInCatalog(role, settings, orgPolicyDefaults(c.cfg), orgId))) {
|
|
372
|
+
return ctx.response.unprocessableEntity(apiErr('invalid_role', 'Role inválida.'));
|
|
373
|
+
}
|
|
374
|
+
if (role === 'owner' && membership.role !== 'owner') {
|
|
375
|
+
return ctx.response.forbidden(apiErr('forbidden', 'Only an owner can grant the owner role.'));
|
|
376
|
+
}
|
|
377
|
+
const result = await c.store.updateOrgMemberRole(orgId, targetId, role);
|
|
378
|
+
if (!result.ok) {
|
|
379
|
+
if (result.reason === 'last_owner') {
|
|
380
|
+
return ctx.response
|
|
381
|
+
.status(409)
|
|
382
|
+
.send(apiErr('last_owner', 'The last owner cannot be demoted.'));
|
|
383
|
+
}
|
|
384
|
+
return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
|
|
385
|
+
}
|
|
386
|
+
await c.cfg.audit?.record({
|
|
387
|
+
type: 'organization.member_role_updated',
|
|
388
|
+
actorId: c.accountId,
|
|
389
|
+
orgId,
|
|
390
|
+
metadata: { orgId, targetAccountId: targetId, role },
|
|
391
|
+
});
|
|
392
|
+
return { ok: true, orgId, accountId: targetId, role };
|
|
393
|
+
}
|
|
394
|
+
// ─── POST /account/api/orgs/invitations/:token/accept ───────────────────
|
|
395
|
+
/**
|
|
396
|
+
* Aceita um convite pelo token do e-mail.
|
|
397
|
+
*
|
|
398
|
+
* Diferente do form (montado FORA do guard para tratar o não-autenticado com
|
|
399
|
+
* um redirect para o login), esta rota vive DENTRO do `accountGuard`: uma
|
|
400
|
+
* tela SPA já está logada, e a resposta a um visitante anônimo aqui teria de
|
|
401
|
+
* ser 401 em JSON, não uma navegação.
|
|
402
|
+
*/
|
|
403
|
+
async acceptInvitation(ctx) {
|
|
404
|
+
const c = await this.#context(ctx);
|
|
405
|
+
if (!c)
|
|
406
|
+
return;
|
|
407
|
+
const token = String(ctx.request.param('token') ?? '');
|
|
408
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
409
|
+
const invitation = await c.store.findInvitationByTokenHash(tokenHash);
|
|
410
|
+
if (!invitation) {
|
|
411
|
+
return ctx.response.notFound(apiErr('not_found', 'Invitation not found.'));
|
|
412
|
+
}
|
|
413
|
+
const result = await c.store.acceptInvitation(invitation.id, c.accountId);
|
|
414
|
+
if (!result.ok) {
|
|
415
|
+
// Os motivos vêm do store e são os MESMOS que o form recebe (e descarta
|
|
416
|
+
// no redirect): o caminho JSON só deixa de escondê-los.
|
|
417
|
+
const status = result.reason === 'expired' ? 410 : result.reason === 'email_mismatch' ? 403 : 409;
|
|
418
|
+
return ctx.response
|
|
419
|
+
.status(status)
|
|
420
|
+
.send(apiErr(result.reason ?? 'accept_failed', 'Invitation could not be accepted.'));
|
|
421
|
+
}
|
|
422
|
+
await c.cfg.audit?.record({
|
|
423
|
+
type: 'organization.invitation_accepted',
|
|
424
|
+
accountId: c.accountId,
|
|
425
|
+
orgId: invitation.organizationId,
|
|
426
|
+
metadata: { orgId: invitation.organizationId, invitationId: invitation.id },
|
|
427
|
+
});
|
|
428
|
+
return { ok: true, organizationId: invitation.organizationId, role: invitation.role };
|
|
429
|
+
}
|
|
430
|
+
// ─── Internos ───────────────────────────────────────────────────────────
|
|
431
|
+
/**
|
|
432
|
+
* Resolve config + store (com orgs) + a conta da sessão. Quando o
|
|
433
|
+
* pré-requisito falha, JÁ RESPONDE e devolve `null` — o caller só precisa de
|
|
434
|
+
* `if (!c) return`.
|
|
435
|
+
*/
|
|
436
|
+
async #context(ctx) {
|
|
437
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
438
|
+
const cfg = service.config;
|
|
439
|
+
const store = cfg.accountStore;
|
|
440
|
+
if (!supportsOrganizations(store)) {
|
|
441
|
+
ctx.response.notFound(apiErr('not_supported', 'Organizations not supported.'));
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
445
|
+
if (!accountId) {
|
|
446
|
+
ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return { cfg, store, accountId };
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Exige que o ator seja owner/admin NA ORG DO PATH. Responde 403 e devolve
|
|
453
|
+
* `null` quando não é.
|
|
454
|
+
*
|
|
455
|
+
* 403 e não 404: as duas negativas — "é membro sem poder" e "não é membro" —
|
|
456
|
+
* saem com o MESMO status, então a resposta não diz a um estranho se a org
|
|
457
|
+
* existe. Saber que lhe falta papel numa org da qual já é membro não lhe
|
|
458
|
+
* conta nada novo.
|
|
459
|
+
*/
|
|
460
|
+
async #requireManager(ctx, store, orgId, actorId) {
|
|
461
|
+
const membership = await store.getOrgMembership(orgId, actorId);
|
|
462
|
+
if (!membership || !MANAGER_ROLES.has(membership.role)) {
|
|
463
|
+
ctx.response.forbidden(apiErr('forbidden', 'Owner or admin role required.'));
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
return membership;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
+
import { normalizeEmailIdentifier } from './email_identifier.js';
|
|
9
10
|
let limiterServicePromise;
|
|
10
11
|
/**
|
|
11
12
|
* Importa o service do limiter do HOST de forma preguiçosa e fail-safe (mesmo
|
|
@@ -32,9 +33,13 @@ export function __setLockoutLimiterLoaderForTests(fn) {
|
|
|
32
33
|
limiterServicePromise = undefined;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Normaliza o email para virar chave estável. Delega à normalização ÚNICA da
|
|
38
|
+
* identidade ({@link normalizeEmailIdentifier}) para que a chave do lockout e a
|
|
39
|
+
* do lookup da conta nunca divirjam.
|
|
40
|
+
*/
|
|
36
41
|
function normalizeEmail(email) {
|
|
37
|
-
return (email
|
|
42
|
+
return normalizeEmailIdentifier(email);
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* Backoff progressivo PURO (sem I/O — fácil de testar): a duração do lock cresce
|
|
@@ -4,6 +4,7 @@ import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
|
|
|
4
4
|
import { PasswordPolicyError } from '../../password/password_manager.js';
|
|
5
5
|
import { AccountDeletionService } from '../account_deletion_service.js';
|
|
6
6
|
import { sendPasswordResetEmail } from '../default_mailer.js';
|
|
7
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
7
8
|
import { authkitOrigin } from '../origin.js';
|
|
8
9
|
import { resolveEffectiveRolesCatalog } from '../runtime_toggles.js';
|
|
9
10
|
/**
|
|
@@ -24,7 +25,14 @@ export class AdminUsersService {
|
|
|
24
25
|
*/
|
|
25
26
|
async create(ctx, input, actor) {
|
|
26
27
|
const store = this.cfg.accountStore;
|
|
27
|
-
|
|
28
|
+
// MESMA normalização do cadastro/login. Os validators já a aplicam, mas o
|
|
29
|
+
// serviço também é chamado direto (console/API/host) — normaliza aqui para
|
|
30
|
+
// que nenhum caminho grave um endereço que o login não encontra.
|
|
31
|
+
const email = normalizeEmailIdentifier(input.email);
|
|
32
|
+
// Duplicado pela MESMA forma que o login busca — a normalizada. Contas
|
|
33
|
+
// gravadas com outra grafia (import/convite antigos) só entram nesta conta
|
|
34
|
+
// depois do `authkit:users:normalize-emails`.
|
|
35
|
+
const existing = await store.findByEmail(email);
|
|
28
36
|
if (existing)
|
|
29
37
|
return { ok: false, reason: 'email_taken' };
|
|
30
38
|
const hasPassword = !!input.password;
|
|
@@ -32,7 +40,7 @@ export class AdminUsersService {
|
|
|
32
40
|
let account;
|
|
33
41
|
try {
|
|
34
42
|
account = await store.create({
|
|
35
|
-
email
|
|
43
|
+
email,
|
|
36
44
|
password: initialPassword,
|
|
37
45
|
fullName: input.name ?? null,
|
|
38
46
|
});
|
|
@@ -52,7 +60,7 @@ export class AdminUsersService {
|
|
|
52
60
|
await this.cfg.audit?.record({
|
|
53
61
|
type: 'user.created',
|
|
54
62
|
accountId: account.id,
|
|
55
|
-
email:
|
|
63
|
+
email: account.email,
|
|
56
64
|
actorId: actor.actorId,
|
|
57
65
|
ip: actor.ip,
|
|
58
66
|
metadata: {
|