# Módulo Acesso (`Infraestrutura/Acesso`)

> Contrato de leitura do módulo (ADR-004/ADR-014) — mantenha sincronizado com o código.

## Propósito

Gerenciar as **contas de operador do painel** e os **perfis de acesso**. Na lista de usuários:
busca/filtro, criar, editar, ativar/desativar e redefinir senha; o perfil é sempre um `core_roles`
**atribuível**. Em **Perfis** (P2.S4): lista com contagem de usuários vinculados, criação/edição da
matriz `{módulo}.{ação}` validada pelo manifesto e exclusão bloqueada por vínculo. Em **Meu perfil**
(P2.S5): cada operador autenticado edita o próprio nome/e-mail e troca a própria senha, **sem**
permissão `acesso.*` e sem tocar em perfil ou situação da conta.

- **Grupo:** `Infraestrutura` (organizacional — não entra no token, ADR-014).
- **Área:** CMS (domínio próprio, ADR-037).
- **Ativação:** dado em `core_module_discovery_settings`; `php artisan core:module:activate Acesso [--disable]` (ADR-034).

## Estrutura

```
src/modules/Infraestrutura/Acesso/
├── ModuleProvider.php
├── MODULE.md
├── Logic/
│   ├── Actions/AcessoUser{Create,Update,Activate,Deactivate,PasswordReset}.php
│   ├── Actions/AcessoRole{Create,Update,Delete}.php
│   ├── Actions/AcessoProfile{Update,PasswordChange}.php
│   ├── Controllers/Cms/AcessoUsers{Index,Create,Store,Edit,Update,Activate,Deactivate,Password}Controller.php
│   ├── Controllers/Cms/AcessoRoles{Index,Create,Store,Edit,Update,Destroy}Controller.php
│   ├── Controllers/Cms/AcessoProfile{Edit,Update,Password}Controller.php
│   ├── Requests/AcessoUser{Store,Update,Password}Request.php
│   ├── Requests/AcessoRoleRequest.php
│   ├── Requests/AcessoProfile{Update,Password}Request.php
│   └── Support/{AcessoUserEmail,AcessoRuleFailure,AcessoPermissionMatrix}.php
├── Routes/cms.php
├── Layout/Views/cms/{acesso-usuarios-index,acesso-usuarios-form,acesso-papeis-index,acesso-papeis-form,acesso-perfil}.blade.php
├── Lang/{pt_BR,en,es,fr}/{menu,cms,validation}.php
└── Test/{AcessoUsersCmsTest,AcessoRolesCmsTest,AcessoProfileCmsTest}.php
```

## Token canônico

**`acesso`** — rotas `acesso.cms.usuarios.*` e `acesso.cms.papeis.*`, classes `Acesso*`, views
`acesso::cms.*` e chaves de tradução no namespace `acesso`. Verificação:
`tools/name-check.sh --grep acesso`.

## Dependências

- **Core:** `Core\Access\Permission`, `Core\Access\AccessPolicy`,
  `Core\Access\Exceptions\AccessRuleViolation`, `Core\Area\Cms\Models\User`,
  `Core\Contracts\Telemetry`, `Core\Contracts\Clock`, `Core\Contracts\AuditEntry`/`AuditWriter`.
- **Layout:** shell `x-layout::cms` e primitivas `x-cms::{table,data-table,form-group,fieldset,confirm,alert,badge,button,pagination}`.
- **Services:** nenhum.
- **Outros módulos:** nenhum import.
- **Tabelas:** `cms_users` (propriedade do Core), `core_roles` (perfis: leitura e escrita da matriz).

## Rotas

| Nome | Método | URI | Controller | Acesso |
|---|---|---|---|---|
| `acesso.cms.usuarios.index` | GET | `/usuarios` | `Cms\AcessoUsersIndexController` | `acesso.ver` |
| `acesso.cms.usuarios.create` | GET | `/usuarios/criar` | `Cms\AcessoUsersCreateController` | `acesso.editar` |
| `acesso.cms.usuarios.store` | POST | `/usuarios` | `Cms\AcessoUsersStoreController` | `acesso.editar` |
| `acesso.cms.usuarios.edit` | GET | `/usuarios/{user}/editar` | `Cms\AcessoUsersEditController` | `acesso.editar` |
| `acesso.cms.usuarios.update` | PUT | `/usuarios/{user}` | `Cms\AcessoUsersUpdateController` | `acesso.editar` |
| `acesso.cms.usuarios.activate` | POST | `/usuarios/{user}/ativar` | `Cms\AcessoUsersActivateController` | `acesso.editar` |
| `acesso.cms.usuarios.deactivate` | POST | `/usuarios/{user}/desativar` | `Cms\AcessoUsersDeactivateController` | `acesso.editar` |
| `acesso.cms.usuarios.password` | POST | `/usuarios/{user}/senha` | `Cms\AcessoUsersPasswordController` | `acesso.editar` |
| `acesso.cms.papeis.index` | GET | `/papeis` | `Cms\AcessoRolesIndexController` | `acesso.ver` |
| `acesso.cms.papeis.create` | GET | `/papeis/criar` | `Cms\AcessoRolesCreateController` | `acesso.editar` |
| `acesso.cms.papeis.store` | POST | `/papeis` | `Cms\AcessoRolesStoreController` | `acesso.editar` |
| `acesso.cms.papeis.edit` | GET | `/papeis/{role}/editar` | `Cms\AcessoRolesEditController` | `acesso.editar` |
| `acesso.cms.papeis.update` | PUT | `/papeis/{role}` | `Cms\AcessoRolesUpdateController` | `acesso.editar` |
| `acesso.cms.papeis.destroy` | DELETE | `/papeis/{role}` | `Cms\AcessoRolesDestroyController` | `acesso.editar` |
| `acesso.cms.perfil.edit` | GET | `/perfil` | `Cms\AcessoProfileEditController` | autenticado (próprio) |
| `acesso.cms.perfil.update` | PUT | `/perfil` | `Cms\AcessoProfileUpdateController` | autenticado (próprio) |
| `acesso.cms.perfil.password` | PUT | `/perfil/senha` | `Cms\AcessoProfilePasswordController` | autenticado (próprio) |

O caminho é relativo ao domínio do painel (`cms.<base>`), que já isola a área: não há prefixo
`/cms` dentro do módulo (a ponte `/cms` do domínio público é só redirecionamento — ADR-005/043).
Nenhuma rota muta por `GET`; toda mutação é `POST`/`PUT` com CSRF. O perfil próprio não usa
`acesso.{ver,editar}`: o alvo é sempre o ator da sessão e nenhum id vem da URL.

## Regras de escrita

- **Nome** `required|max:120`; **e-mail** `email|max:255`, normalizado e único sem diferenciar
  caixa (inclusive contra contas soft-deleted — a identidade excluída não é reciclada). O endereço
  reservado da equipe (`suporte@maxima.inf.br`) só pode pertencer à conta de suporte: criação,
  edição administrativa e perfil próprio recusam **422** `support_identity` para quem não é a
  equipe; o provisionamento legítimo é o CLI `core:user:create --role=suporte`.
- **Perfil** `required`, existente e **atribuível** (`Role::assignable()`); a ação confirma
  novamente pela `AccessPolicy` (o `suporte` nunca é atribuído).
- **Senha** `min:12|confirmed` na criação e na redefinição; na edição é opcional (vazia mantém a
  atual). O cast `hashed` do model aplica **Argon2id**; senha nunca aparece em log/trilha.
- `AccessPolicy` recusa **403** sem `acesso.editar`; self-desativação, último administrador,
  perfil/conta `suporte` e auto-lockout viram **422** com mensagem i18n (nunca 500). Ninguém além
  da própria equipe redefine a senha, envia senha na edição ou desativa a conta `suporte`
  (`assertUserNotProtected`).
- **Ordem global de locks** nas escritas: perfis administradores (`id`) → usuários administradores
  ativos (`id`) → perfil-alvo → conta-alvo (`lockRoleForWrite` antes de `lockUserForWrite` na edição;
  desativar/ativar/reset só travam a conta). O perfil de destino é relido sob lock (o snapshot do
  request não é autoridade) — sem ciclo 40P01 entre escritas concorrentes.
- Desativar grava `deactivated_at` (data, não flag); o login recusa conta inativa e o guard da área
  encerra a sessão aberta no próximo request (P2.S1).
- Mutação de `User`/`Role` entra na trilha pelo `GlobalAuditSubscriber` (ADR-035) com before/after;
  `password` é mascarado. O `permissoes` do perfil aparece no diff (`changes`) para evidência.
  Além da trilha ambiente, as mutações privilegiadas emitem evento **explícito** com vocabulário
  `acesso.*`: `acesso.user_role_changed`, `acesso.user_password_reset` e
  `acesso.role_permissions_changed` (ids/tokens, sem PII/senha).
- **Perfil próprio (S5):** só `name` (`required|max:120`) e `email` (`email|max:255`, normalizado e
  único sem caixa, inclusive contra contas soft-deleted). `role_id`, `deactivated_at` e
  `email_verified_at` nunca vêm do formulário próprio. Senha: senha atual conferida com
  `Hash::check`, nova `min:12|confirmed` pelo cast `hashed` (Argon2id), `session()->regenerate()`
  após a troca; no MVP só a sessão corrente é regenerada. O e-mail do `suporte` continua reservado.
- **Perfis (S4):** `nome` `required|max:120`, único sem diferenciar caixa (request + Action, para
  não deixar dois `<option>` idênticos); `slug` derivado do nome na criação e **estável** na
  edição; `permissoes[]` só aceita token `{modulo}.{acao}` **declarado** no catálogo. Duplicatas são
  normalizadas; token órfão já gravado é avisado, preservável ou removível, e **nunca** concedido a
  perfil novo. Perfil protegido (`suporte`) é recusado na rota e no model e **não é listado** em
  `/papeis` (ADR-033 §3); exclusão com qualquer usuário vinculado (inclusive soft-deleted) vira
  **422** i18n — nunca 500.

## Permissões

`acesso.{ver|editar}` declaradas em `ModuleProvider::config()` e aplicadas com
`Permission::authorize()` (deny-by-default, ADR-033).

## Eventos e observabilidade

- Ações emitem **span + `log_event` com o mesmo nome**: `acesso.user_created`,
  `acesso.user_updated`, `acesso.user_activated`, `acesso.user_deactivated`,
  `acesso.user_password_reset`, `acesso.role_created`, `acesso.role_updated`,
  `acesso.role_deleted`, `acesso.profile_updated`, `acesso.password_changed` (ADR-024). Payload só
  com escalares (`id`, `actor`, contagem de permissões, booleano de mudança de senha) — sem
  PII/segredo.
- A trilha ambiente é do Core (`GlobalAuditSubscriber`); os eventos explícitos de privilégio são
  gravados pelas Actions via `AuditWriter` (ADR-035).

## Layout

- `cms/acesso-usuarios-*`, `cms/acesso-papeis-*` e `cms/acesso-perfil` usam **um único**
  `<x-layout::cms>`; o módulo preenche apenas o conteúdo (o `<h1>` é do shell — ADR-009/ADR-016).
- Tabela com `caption` e `th scope="col"`; a célula de ações é a do componente (`x-cms::table-actions`
  já emite `<td>` — nunca aninhar outra); situação em **texto** (badge com rótulo, não só cor);
  filtros em `GET`, paginação nativa com o componente da casa; ações de linha em `POST` com
  confirmação nativa (`<x-cms::confirm>`, popover sem JS).
- **Matriz de perfis:** tabela semântica com `caption`, `scope="col|row|rowgroup"`, as quatro ações
  fixas como colunas; ação não declarada é renderizada **desabilitada** (nunca omitida) e a
  operação é possível só com teclado (checkboxes nativos, sem JS). Resumo de módulos com `editar`,
  aviso de tokens órfãos e contagem de usuários vinculados.
- Formulário com `fieldset/legend`, rótulo por controle, `aria-invalid`/`aria-describedby` no erro,
  resumo `role="alert"` e foco no primeiro campo inválido. Alvos ≥ 2.75rem pelas primitivas.
- **Meu perfil:** dois formulários independentes (`PUT /perfil` e `PUT /perfil/senha`) com
  `fieldset/legend`, rótulo por controle, `autocomplete` de senha (`current-password`/
  `new-password`), foco no primeiro erro e nenhuma senha preenchida após o envio.
- 4 locales no namespace `acesso`; nenhuma string visível fixa no Blade/PHP.

## Comandos

```bash
php artisan core:module:activate Acesso
php artisan core:module:activate Acesso --disable
```

## Convenções

- [x] `declare(strict_types=1)`; DI; sem traits/abstract/generics/`Result`.
- [x] Ações focadas (uma por arquivo) com `Telemetry`; controllers finos.
- [x] Sem `new`/`resolve`/`app` em corpo de domínio; controllers não contêm regra de negócio (a
  listagem consulta via Eloquent com eager loading, como nas telas irmãs).
- [x] Nenhum segredo/PII em log, auditoria, URL ou fixture.
