Profile Server API
Endpoints

Users

CRUD de usuários locais vinculados a identidades Keycloak.

Perfis locais vinculados a uma identidade do Keycloak (idIdentity = sub). Base path: /api/users · autenticação de usuário · header Authorization: Bearer <token>.

MétodoRotaScopeResourceAção (role)Sucesso
POST/api/userscreateresource:userscreate201
GET/api/usersreadresource:usersread200
GET/api/users/:idreadresource:users_idread200
PUT/api/users/:idupdateresource:users_idupdate200
DELETE/api/users/:iddeleteresource:users_iddelete204
POST/service/usersserviço201

Controle de acesso por persona. Módulo users é exclusivo da persona global — nem tenant-admin (excluído ⏳), technician nem viewer entram por padrão (só via role de exceção users:acao). Token sem role suficiente → 403 NOT_ALLOWED. Isso inclui as rotas de vínculo com tenant (/api/users/:userId/tenants).


Criar usuário

POST /api/users — resolve a identidade no Keycloak pelo e-mail (que é o login) e a vincula ao perfil local.

  • Se já existe um usuário com aquele e-mail no Keycloak, o sub dele é reaproveitado como idIdentity.
  • Se não existe, a identidade é criada no Keycloak (habilitada, sem senha, com as required actions UPDATE_PASSWORD e VERIFY_EMAIL) e um e-mail de definição de senha é enviado. Se o envio falhar (ex.: SMTP fora do ar), o usuário ainda é criado — a required action fica pendente e o e-mail pode ser reenviado pelo console do Keycloak.

O idIdentity não é enviado no corpo: ele é derivado do Keycloak.

Corpo

CampoTipoObrigatórioRegras
namestringsimmín. 1 caractere
emailstringsime-mail válido, único
federalTaxIdstringsimCPF, 11 dígitos, com dígito verificador válido, único
typeenumsimAdmin | Coordinator | Technician | Cooperative | GlobalGlobal dá acesso total (bypass de todo o RBAC); só quem já é Global acessa este endpoint
jobTitlestringnãocargo do usuário
customerIduuidnãopode ser null; se informado, Customer existente (404 se não existir)
curl -X POST http://localhost:3000/api/users \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "federalTaxId": "52998224725",
    "type": "Admin",
    "jobTitle": "Analista"
  }'

201 Created

{
  "id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
  "idIdentity": "a3f5c9d2-keycloak-sub",
  "name": "Jane Doe",
  "email": "jane.doe@example.com",
  "federalTaxId": "52998224725",
  "jobTitle": "Analista",
  "type": "Admin",
  "customerId": null,
  "createdAt": "2026-07-10T12:00:00.000Z",
  "updatedAt": "2026-07-10T12:00:00.000Z"
}

Erros

StatuscodeQuando
409USER_ALREADY_EXISTSJá existe um usuário com esse email ou federalTaxId
409IDENTITY_ALREADY_LINKEDA identidade encontrada no Keycloak já está vinculada a outro usuário local (idIdentity é único)
404CUSTOMER_NOT_FOUNDO customerId informado não existe
503IDENTITY_PROVIDER_UNAVAILABLENão foi possível consultar ou criar a identidade no Keycloak

O backend fala com a Admin REST API do Keycloak usando o service account do client (client_credentials). O client precisa das roles view-users e manage-users de realm-management no realm — sem elas, toda criação de usuário responde 503.


Criar ou vincular usuário ao tenant (app-to-app)

POST /service/users — autenticado por ServiceAuthMiddleware (token de serviço, client_credentials). O tenantId nunca vem no corpo: é sempre o do chamador, resolvido do token.

Usado por outros serviços/apps parceiros para provisionar um usuário e já vinculá-lo ao próprio tenant, verificando duplicidade por CPF (federalTaxId) e e-mail:

  • Se nem o CPF nem o e-mail existirem em nenhum usuário → cria o usuário (resolvendo/criando a identidade no Keycloak, igual ao POST /api/users) e já o vincula ao tenant do token.
  • Se o CPF e o e-mail já existirem e apontarem para o mesmo usuário → não cria nada, nem toca no Keycloak; apenas garante o vínculo com o tenant do token (idempotente).
  • Se só um dos dois já existir, ou se apontarem para usuários diferentes409 USER_DATA_MISMATCH — nada é criado, atualizado ou vinculado.

Corpo

CampoTipoObrigatórioRegras
namestringsimmín. 1 caractere
emailstringsime-mail válido
federalTaxIdstringsimCPF, 11 dígitos, com dígito verificador válido
typeenumsimAdmin | Coordinator | Technician | Cooperative — sem Global de propósito: provisionamento app-to-app nunca cria usuário com acesso total. Promover a Global é só pelo painel (ver Editar usuário).
personaenumsimtenant-admin | coordinator | technician | viewer — persona de acesso (RBAC) no tenant do token. Independente de type (que controla só o escopo de leitura por organization). Nunca global. Upsert: religar com persona diferente atualiza a persona do vínculo.
jobTitlestringnãocargo do usuário
curl -X POST http://localhost:3000/service/users \
  -H "Authorization: Bearer $SERVICE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "federalTaxId": "52998224725",
    "type": "Technician",
    "persona": "technician",
    "jobTitle": "Analista"
  }'

201 Created

{
  "id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
  "idIdentity": "a3f5c9d2-keycloak-sub",
  "name": "Jane Doe",
  "email": "jane.doe@example.com",
  "type": "Technician",
  "federalTaxId": "52998224725",
  "jobTitle": "Analista",
  "customerId": null,
  "createdAt": "2026-07-10T12:00:00.000Z",
  "updatedAt": "2026-07-10T12:00:00.000Z"
}

Erros

StatuscodeQuando
409USER_DATA_MISMATCHfederalTaxId e email não apontam para o mesmo usuário existente
409IDENTITY_ALREADY_LINKEDA identidade encontrada no Keycloak já está vinculada a outro usuário local
503IDENTITY_PROVIDER_UNAVAILABLENão foi possível consultar ou criar a identidade no Keycloak

Listar usuários

GET /api/users — lista paginada.

Query params

ParamTipoPadrãoDescrição
pagenumber1inteiro, mín. 1
limitnumber20inteiro, 1 a 100
namestringfiltro por nome
emailstringfiltro por email
typeenumAdmin | Coordinator | Technician | Cooperative | Global
customerIdstringfiltro por customer
curl "http://localhost:3000/api/users?page=1&limit=20&type=Admin" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

200 OK

{
  "data": [
    {
      "id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
      "idIdentity": "a3f5c9d2-keycloak-sub",
      "name": "Jane Doe",
      "email": "jane.doe@example.com",
      "federalTaxId": "52998224725",
      "jobTitle": "Analista",
      "type": "Admin",
      "customerId": null,
      "createdAt": "2026-07-10T12:00:00.000Z",
      "updatedAt": "2026-07-10T12:00:00.000Z"
    }
  ],
  "total": 1,
  "totalPages": 1,
  "currentPage": 1
}

Obter usuário por id

GET /api/users/:id — retorna um único usuário.

Erros

StatuscodeQuando
404USER_NOT_FOUNDUsuário não existe

Editar usuário

PUT /api/users/:id — atualização parcial (todos os campos opcionais).

CampoTipoRegras
namestringmín. 1 caractere
emailstringe-mail válido
typeenumAdmin | Coordinator | Technician | Cooperative | Global
jobTitlestringpode ser null (limpa o cargo)
customerIduuidpode ser null; se informado, Customer existente

federalTaxId não é editável (imutável após a criação).

Promover a Global (acesso total, bypass de todo o RBAC — RolesGuard e PermissionGuard) é só { "type": "Global" } aqui. Não requer nenhuma configuração no Keycloak. Só quem já é Global pode chamar este endpoint (Module.Users é exclusivo dessa persona), então só um Global promove outro.

Retorna 200 com o usuário atualizado.

Erros

StatuscodeQuando
404USER_NOT_FOUND · CUSTOMER_NOT_FOUNDO usuário — ou o customerId informado — não existe
409USER_ALREADY_EXISTSJá existe outro usuário com esse email

Remover usuário

DELETE /api/users/:idsoft delete. Retorna 204 No Content.

Erros

StatuscodeQuando
404USER_NOT_FOUNDUsuário não existe

Memberships (tenants do usuário)

Gerenciam a quais tenants o usuário pertence. É a origem do escopo de leitura: um usuário só enxerga cooperativas/documentos dos tenants dos quais é membro (ver também Organization Tenants).

MétodoRotaScopeResourceSucesso
GET/api/users/:userId/tenantsreadresource:users_id200
POST/api/users/:userId/tenantsupdateresource:users_id201
DELETE/api/users/:userId/tenants/:tenantIdupdateresource:users_id204
GET/api/tenants/:tenantId/usersreadresource:tenants_id200

O lado inverso — listar os usuários de um tenant — é GET /api/tenants/:tenantId/users (mesma relação user_tenants, pela ótica do tenant). Vincular/desvincular continua sendo via as rotas de usuário acima.

Listar tenants do usuário

GET /api/users/:userId/tenants — retorna os tenants a que o usuário pertence, com a persona atribuída em cada vínculo. 404 USER_NOT_FOUND se o usuário não existir.

{
  "data": [
    {
      "id": "t1b2c3d4-e5f6-4789-a123-456789abcdef",
      "customerId": "c1b2c3d4-e5f6-4789-a123-456789abcdef",
      "appId": "a1b2c3d4-e5f6-4789-a123-456789abcdef",
      "name": "Acme Tenant",
      "active": true,
      "persona": "technician"
    }
  ]
}

persona pode vir null — vínculo sem persona atribuída (legado, sem backfill, ou criado sem reatribuição posterior). Nesse estado o usuário toma 403 em qualquer request feita com aquele tenant ativo, até alguém religar com POST /api/users/:userId/tenants informando a persona.

Vincular usuário a um tenant

POST /api/users/:userId/tenants — upsert: se o vínculo já existir, atualiza a persona em vez de duplicar.

CampoTipoObrigatórioRegras
tenantIduuidsimTenant existente (404 TENANT_NOT_FOUND se não existir)
personaenumsimtenant-admin | coordinator | technician | viewer — persona do usuário nesse tenant. Nunca global.
curl -X POST http://localhost:3000/api/users/$USER_ID/tenants \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "t1b2c3d4-e5f6-4789-a123-456789abcdef",
    "persona": "technician"
  }'

201 Created

{
  "userId": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
  "tenantId": "t1b2c3d4-e5f6-4789-a123-456789abcdef"
}

Erros

StatuscodeQuando
404USER_NOT_FOUND · TENANT_NOT_FOUNDUsuário ou tenant não encontrado

Desvincular usuário de um tenant

DELETE /api/users/:userId/tenants/:tenantId — idempotente. Retorna 204.

Erros

StatuscodeQuando
404USER_NOT_FOUNDUsuário não existe

Escopo de cooperativas do usuário no tenant

Dentro de cada tenant, restringe quais cooperativas aquele usuário específico enxerga — um nível abaixo da membership (ver PLANO-RESTRICAO-ORGANIZACAO-POR-USUARIO.md). Exclusivo da persona global; tenant-admin não configura isso, mesmo tratamento de users.

MétodoRotaScopeResourceSucesso
GET/api/users/:userId/tenants/:tenantId/organizationsreadresource:user-tenant-organizations200
PUT/api/users/:userId/tenants/:tenantId/organizationsupdateresource:user-tenant-organizations204

GET retorna o modo (ALL/RESTRICTED), os ids concedidos e effectiveScope:

{
  "scope": "RESTRICTED",
  "organizationIds": ["o1b2c3d4-e5f6-4789-a123-456789abcdef"],
  "effectiveScope": "RESTRICTED"
}

effectiveScope vira ALL_BY_PERSONA quando o usuário-alvo é tenant-admin nesse tenant — ele enxerga todas as cooperativas independente do que estiver salvo em scope/ organizationIds. É um valor distinto de ALL, pra UI deixar claro que a origem é a persona, não a configuração salva.

PUT substitui o conjunto inteiro — não é add/remove granular:

curl -X PUT http://localhost:3000/api/users/$USER_ID/tenants/$TENANT_ID/organizations \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scope": "RESTRICTED",
    "organizationIds": ["o1b2c3d4-e5f6-4789-a123-456789abcdef"]
  }'
CampoTipoObrigatórioRegras
scopeenumsimALL ou RESTRICTED
organizationIdsuuid[]só p/ RESTRICTEDCada id precisa estar vinculado a esse tenant; lista vazia é permitida e bloqueia o usuário no tenant

Retorna 204. Com scope: "ALL", organizationIds é ignorado — nenhum grant precisa existir nesse modo.

Erros

StatuscodeQuando
404USER_NOT_FOUNDUsuário não existe
404USER_TENANT_NOT_FOUNDUsuário não é membro ativo desse tenant
400ORGANIZATION_NOT_LINKED_TO_TENANTUm ou mais organizationIds não estão vinculados a esse tenant