Skip to content

feat(API Keys): Chaves de integração user-scoped (FIXR-35) - #86

Merged
risixdzn merged 13 commits into
developfrom
femartinezmelo/fixr-35-feature-chaves-de-integracao-api-keys
Sep 22, 2026
Merged

risixdzn merged 13 commits into
developfrom
femartinezmelo/fixr-35-feature-chaves-de-integracao-api-keys

Conversation

@felipemartinezmelo

@felipemartinezmelo felipemartinezmelo commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Implementa chaves de API para acesso programático ao Fixr (FIXR-35), cobrindo schema, API, autenticação e tela de gerenciamento.

Modelo de acesso

As chaves são user-scoped: pertencem a quem as criou e carregam exatamente as permissões do cargo desse usuário.

  • O cargo é resolvido em tempo de request, nunca copiado para a chave. Rebaixar ou remover um funcionário estreita automaticamente todas as chaves dele.
  • scopes[] é opcional e só restringe — nunca amplia além do que o criador possui. Vazio significa "herda o cargo como está".
  • Cada funcionário vê e revoga apenas as próprias chaves, admins incluídos. Uma chave de outro dono responde 404 em vez de 403, para o endpoint não virar um oráculo de IDs.
  • apiKeys:read|create|revoke entrou em baseEmployee, então é self-service para todo funcionário (guest segue sem).

A issue original descrevia o recurso como restrito a admins e gerentes, sobre um pool compartilhado da empresa. A descrição no Linear foi atualizada para o modelo acima.

Endpoints

Método Rota
GET /companies/:subdomain/api-keys — lista as chaves do próprio usuário (só o prefixo público)
POST /companies/:subdomain/api-keys — cria e devolve o secret uma única vez
DELETE /companies/:subdomain/api-keys/:id — revoga (soft delete, preserva auditoria)

Autenticação via x-api-key ou Authorization: Bearer <token>.

Decisão técnica: HMAC-SHA256 em vez de bcrypt

A issue pedia bcrypt. Bcrypt é lento de propósito, o que é correto para senha de usuário (baixa entropia), mas custa ~100ms em toda requisição autenticada — inviável numa API de integração e um vetor de DoS.

Chaves têm 256 bits de entropia aleatória: não há dicionário a atacar, então a lentidão não compra segurança. HMAC-SHA256 com pepper (API_KEY_SECRET) mantém a verificação em microssegundos e torna um dump da tabela inútil sem a env var. É o que GitHub, Stripe e AWS fazem.

Está isolado em hashApiKeySecret / verifyApiKeySecret, em um único arquivo, caso a decisão mude.

Performance e segurança

  • Lookup por prefix com índice único: uma query por autenticação, e só então a verificação do secret em tempo constante (timingSafeEqual).
  • last_used_at com throttle de 60s via SET NX no Redis — sem isso, toda chamada de leitura viraria uma escrita, com uma linha sob contenção constante.
  • O secret em texto puro nunca é persistido nem devolvido de novo; no frontend ele sai do estado assim que o diálogo fecha.

Frontend

/dashboard/:subdomain/settings/api-keys, seguindo o módulo de funcionários (react-query + TanStack Table + dialogs). O seletor de permissões só oferece o que o usuário logado possui, então a UI nem consegue expressar uma chave que supere o criador.

Correções de bugs pré-existentes

Três defeitos que bloqueavam o build e vieram de commits anteriores nesta base. Estão em commits separados para facilitar cherry-pick:

  • 3075673 — marcadores de merge (<<<<<<<) commitados em account/repositories, quebrando o build do server
  • 2a8374cuse-as-ref.ts indentado com espaços, quebrando o biome check que roda antes do next build

⚠️ Pendência conhecida (não corrigida aqui)

bun run db:migrate não roda num banco limpo. A migration 0012_wooden_goliath faz RENAME COLUMN uploads.purpose e MODIFY model_images.upload_id — colunas que nenhuma migration anterior cria. Ela foi gerada contra um banco sincronizado via db:push. Isso quebra CI e qualquer ambiente novo, e merece uma issue própria.

Setup necessário

Nova env var obrigatória no server (já documentada no .env.example):

API_KEY_SECRET='' # mínimo 32 caracteres

Validação

  • Lint e type-check passando nos 5 workspaces
  • Build de produção do web OK, rota em 15.9 kB
  • Endpoints verificados contra MySQL e Redis reais, com as rotas registradas no /openapi.json

Fora de escopo

Rate limiting foi extraído para a FIXR-44, em branch própria. É uma capacidade transversal a toda a API e não deveria entrar como efeito colateral desta feature.

Testes automatizados. A cobertura que eu priorizaria: scope não pode escalar privilégio, e chave revogada/expirada é recusada.

felipemartinezmelo and others added 3 commits September 14, 2026 19:46
Commit 990eba6 landed conflict markers in the account repository, breaking
the TypeScript build on develop.

The incoming side imported accountCacheKey/jwtPayloadCacheKey from
core/lib/cache.ts, which da24aeb removed when the cache decorators replaced
the manual helpers. Resolved in favour of the decorator pattern and converted
updateAvatarUrl to @InvalidateCache, matching how credentials already
invalidates those same keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds company-scoped integration keys following the existing module pattern
(routes/controllers/services/repositories/errors + OpenAPI docs).

- api_keys table: prefix is a uniquely indexed public handle, only the HMAC
  of the secret is persisted, revocation is a soft delete
- GET/POST/DELETE /companies/:subdomain/api-keys behind a dedicated
  apiKeys:read|create|revoke permission group granted to manager and admin
- authenticateApiKey middleware resolves the key by prefix in one indexed
  query, verifies the secret in constant time and populates request.ability

Scopes can only narrow: the owning employee's role is resolved at request
time and intersected with the stored scopes, so demoting an employee
immediately narrows every key they issued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creates the api_keys table with a unique index on prefix, which backs the
single-query lookup performed on every API key authentication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 14, 2026

Copy link
Copy Markdown

FIXR-35

felipemartinezmelo and others added 3 commits September 14, 2026 20:58
The file landed in f303427 indented with spaces, which fails the biome check
that `build:web` runs before next build — breaking the web build on develop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds /dashboard/:subdomain/settings/api-keys following the employees module
pattern: a react-query list, a TanStack table and dialogs for the mutations.

- The secret appears once, in the creation dialog, behind an explicit warning
  and a copy button; it is dropped from state as soon as the dialog closes
- The scope picker only offers permissions the signed-in employee holds, so
  the UI cannot even express a key that outranks its creator
- Revoking asks for confirmation naming the key, since it takes effect
  immediately and cannot be undone
- Create and revoke controls are gated on apiKeys:create / apiKeys:revoke,
  deriving the ability from the session the way the sidebar does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The task described keys as an admin/manager feature over a shared company
pool. They are instead an extension of the employee who creates one, so the
model now follows ownership rather than company membership.

- apiKeys read/create/revoke moved into baseEmployee: every employee manages
  their own keys, and guest still holds none
- Listing is filtered by employee, so nobody sees another person's keys —
  admins included
- Revoke resolves by owner; a key belonging to someone else answers 404
  rather than 403, so the endpoint cannot be used to probe for key IDs
- Name uniqueness is per employee, not per company

Scopes keep narrowing-only semantics, and the role is still resolved at
request time, so a key never outlives or outranks its owner's access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@felipemartinezmelo
felipemartinezmelo force-pushed the femartinezmelo/fixr-35-feature-chaves-de-integracao-api-keys branch from 805ce09 to d1e0e9b Compare September 14, 2026 23:59
@felipemartinezmelo felipemartinezmelo changed the title feat(API Keys): Chaves de integração user-scoped com rate limiting (FIXR-35) feat(API Keys): Chaves de integração user-scoped (FIXR-35) Sep 15, 2026
felipemartinezmelo and others added 3 commits September 15, 2026 09:52
The middleware existed but no route used it, so every request carrying a
fxr_ token hit authenticateEmployee, failed jwtVerify and came back as
auth_jwt_invalid. The feature shipped a credential that opened nothing.

authenticateEmployeeOrApiKey picks the credential by the shape of the token
instead of trying one and falling back, so a bad key reports why it failed
rather than masquerading as a bad JWT. Both paths populate request.user and
request.ability identically, so requirePermission is unaware of the
difference.

Applied to the domain modules only. Routes tied to a human session and the
api-keys routes themselves stay JWT-only, so a leaked key cannot mint more
keys or perpetuate itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revoking always failed with 400. The param schema required exactly 25
characters, a number taken from the varchar(25) column width, but cuid2
generates 24, so no real id ever passed validation.

Switched to z.string().cuid2(), the convention already used in auth.ts,
account.ts and models.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…IXR-47, FIXR-48)

Creation moves from a centered dialog to a right side sheet, matching the
service order form. While the secret is on screen it exists nowhere else, so
closing by ESC or by clicking outside is blocked during that step and only
the explicit button dismisses it.

Visual and copy fixes from review:

- Form fields had no breathing room, spacing widened
- Page subtitle trimmed to one line; the detail about permissions and the
  secret already appears in the form and in the reveal alert
- No icon in the creation heading
- Status badges no longer use the primary colour. Revoked keeps a hint of
  destructive since it is a deliberate action; expired stays muted
- The revoke icon now follows the destructive colour of its label
- The reveal alert was destructive, which reads as failure. The user just
  succeeded and needs to act, so it is now a warning in amber, which meant
  adding a warning variant to the Alert component
- Em dashes removed from every string in the feature

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@risixdzn risixdzn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ajustes

API

Ainda dá o mesmo erro na hora de revogar as chaves
500 DELETE /companies/acme/api-keys/raegm5rlhmw2qdb5kdz2f4f2

{"status":500,"error":"Internal Server Error","message":"[\n  {\n    \"expected\": \"string\",\n    \"code\": \"invalid_type\",\n    \"path\": [\n      \"subdomain\"\n    ],\n    \"message\": \"Invalid input: expected string, received undefined\"\n  }\n]","code":"internal_error","data":{"message":"[\n  {\n    \"expected\": \"string\",\n    \"code\": \"invalid_type\",\n    \"path\": [\n      \"subdomain\"\n    ],\n    \"message\": \"Invalid input: expected string, received undefined\"\n  }\n]","stack":"ZodError: [\n  {\n    \"expected\": \"string\",\n    \"code\": \"invalid_type\","}}

Não consigo acessar os endpoints da aplicação com o token gerado.
O comportamento desejado é que o token possa ser utilizado em TODOS endpoints, da mesma forma que um usuário interagiria com seu Bearer + refreshToken

O request seguinte

curl /account/ \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer fxr_Ytr-MUHCCu3P_SvcQmaiJVBOArSwxcb8m9I--NwskvikComL2xexTlcU'

Tem essa resposta (mesma coisa para todos outros endpoints que testei)

{
  "status": 401,
  "error": "Unauthorized",
  "message": "Authorization token is invalid or expired.",
  "code": "auth_jwt_invalid",
  "data": null
}

Front

Ficou legal a Sheet no formulário de criação mas acho que ficaria melhor se fosse replicado o mesmo comportamento do form de nova ordem de serviço.
Uma rota /new que usa o conceito de parallel routes do NextJS, interceptada por um dialog / vaul headless que aparece com o formulário acima da página atual.

Também falta feedback de Toast na hora de criar.

Validating the id as cuid2 was only half the fix. The route schema declared
params as apiKeyIdParamsSchema, which only knows apiKeyId, but the route is
mounted under the /companies/:subdomain/api-keys prefix.

Fastify replaces request.params with whatever the validator returns, and zod
strips every key it does not know about, so subdomain was gone by the time the
handler ran and getCompanyNestedDataSchema.parse(request.params) threw. Revoke
kept failing, now with a 500 instead of a 400.

The params schema now declares both, the way every other nested route already
does. This also fixes the OpenAPI spec, which was missing the subdomain path
param for this endpoint.
The side sheet is replaced by the same ResponsiveDialogDrawer the service
order and employee forms use, so every creation form opens the same way:
a centered dialog on desktop, a vaul drawer on mobile, with the icon
header.

That component only served intercepted route modals, so it now takes
optional open/onOpenChange, a trigger and a dismissible flag. Without
them it keeps closing through router.back(), leaving the existing route
modals untouched.

While the secret is on screen it exists nowhere else, so the reveal step
passes dismissible={false}: escape, outside clicks and the close button
are blocked, and only the explicit button closes.
It was the only package pointing at it through the catalog, which resolves
to a version instead of the workspace, so the package was never linked
and the tsconfig "extends" did not resolve from there.
…FIXR-45)

A token is fxr_<prefix>_<secret> and both parts are base64url, an
alphabet that includes "_". Parsing split the token on that character and
required exactly three pieces, so any key that happened to draw a "_"
was rejected: measured over 20k generated keys, 57% of them could never
authenticate. The dispatch middleware reads a failed parse as "this is
not an API key" and falls through to the JWT path, which is why the
symptom was auth_jwt_invalid rather than an API key error.

Both parts have a fixed encoded length, 12 and 43 characters, so the
separator between them is located by offset instead of by splitting.
Existing keys keep working, since neither the layout nor the encoding
changes.

The spec needs vitest to transform an import of @fixr/env, hence the
config: the server had the runner wired but no suite yet.
@risixdzn
risixdzn merged commit 539f572 into develop Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants