Repository navigation
fix(auth): les jetons API generes depuis Reglages nauthentifient rien et ne persistent pas #782
Description
Problème
Dans Réglages, un utilisateur saisit un nom de jeton — le champ propose lui-même « ex. CI/CD » —
clique sur « Générer un jeton », et reçoit le message « Jeton créé et copié dans le presse-papiers ».
Il croit tenir une clé d'API utilisable dans sa CI. Il ne tient rien. Trois défauts se cumulent sur
ce seul parcours :
- Le jeton n'authentifie rien. Le middleware qui vérifie les jetons
orsk_
(backend/internal/middleware/tokenauth.go) n'est monté nulle part dans
backend/cmd/server/main.go. Aucune requête portant ce jeton ne sera jamais acceptée. - Le jeton ne survit pas à un redémarrage.
TokenServiceconserve les jetons dans une map
mémoire (backend/internal/service/token_service.go:29-31). Aucune migration ne crée la table
api_tokens;domain.APITokenn'est passé àAutoMigrateque dans
backend/internal/handler/test_helpers.go:44, donc en test uniquement. - Le presse-papiers ne reçoit pas le jeton. Le handler renvoie
{"token": {..., "value": "orsk_…"}}
(backend/internal/handler/token_handler.go:55-66), mais le client le type
api.post<{ token?: string }>(frontend/src/features/settings/adminData.ts:74) et copie
res.data?.token(frontend/src/features/settings/SettingsScreen.tsx:414-416). L'objet est
truthy, doncwriteTextreçoit[object Object]et le toast annonce quand même la réussite.
Pendant ce temps, un second service de jetons, persistant et réellement branché sur
l'authentification, existe : PersonalAccessTokenService (GORM), exposé sur /auth/pat et monté
via middleware.PATMiddleware (backend/cmd/server/main.go:938, :1113-1115). L'interface
n'expose pas celui-là. Deux implémentations de la même idée, l'UI branchée sur la mauvaise.
C'est le défaut que docs/W0-05_DECEPTIVE_UI_AUDIT.md traque : l'interface affirme un succès qui
n'a pas eu lieu. Dans un produit de conformité vendu sur la non-surdéclaration, un client qui
découvre que le jeton de son pipeline n'a jamais rien authentifié n'en tire pas une conclusion
limitée à cet écran.
À noter pour le correctif : domain.APIToken ne porte pas de champ TenantID
(backend/internal/domain/api_token.go:36), et ListTokens ne filtre que sur UserID
(backend/internal/service/token_service.go:166-178). Toute persistance de ces jetons devra porter
le tenant_id et filtrer dessus — règle absolue n°2 de CLAUDE.md.
Critères d'acceptation
- Un jeton créé depuis Réglages authentifie une requête API réelle : un appel portant ce jeton sur
un endpoint protégé répond 200, et le même appel sans jeton répond 401. - Un jeton créé survit à un redémarrage du serveur, prouvé par un test d'intégration qui écrit puis
relit depuis PostgreSQL. - La valeur secrète effectivement placée dans le presse-papiers commence par
orsk_; un test
frontend couvre le succès et interdit la régression[object Object]. - Le jeton porte un
tenant_id; un jeton du tenant A est invisible et inutilisable depuis le
tenant B, prouvé par un test cross-tenant. - Il ne reste qu'une implémentation de jeton d'API. Si
PersonalAccessTokenServiceest retenu,
TokenService,tokenauth.goet les routes/tokenssont supprimés et l'UI est rebranchée sur
/auth/pat; si c'est l'inverse, la justification est écrite dans une ADR. - Tant que le parcours n'est pas fonctionnel, l'écran n'annonce aucun succès — pas de toast
« créé et copié » sur une opération qui ne produit pas de jeton utilisable.
Definition of Done
Un utilisateur qui génère un jeton depuis Réglages peut le coller dans un pipeline CI et appeler
l'API avec, après un redémarrage du serveur.
Activity
alex-dembele commentedon Sep 25, 2026
audit — 2026-09-25
Triage: this issue is the canonical one for the API-token defect. #709 describes the same defect with fewer criteria; it is marked as a duplicate of this one. Anything #709 adds is kept here: nothing, since criteria 1-6 cover it.
Labels completed (
priority:P0-critical,status:ready). Placed in Sprint 1 of the launch board.Next: Sprint 1 of the launch board.
Blocked on: nothing
Generated by Claude Code
alex-dembele commentedon Oct 1, 2026
work — 2026-10-01
Done — Claimed the issue, created branch
782-fixauth-les-jetons-api-generes-depuis-reglages-nauthentifient-rien-et-ne-persistent-pasfrom master (583a52b), and mapped both token stacks. No code changed yet: the session was halted by a local permission block on the first edits.Design chosen (criterion 5): keep
PersonalAccessTokenService, deleteTokenService. The PAT service is already DB-backed (domain.PersonalAccessTokenis incmd/server/schema.goAutoMigrate) and mounted (middleware.PATMiddleware,cmd/server/main.go:972; routes:1147-1149). No ADR is needed on this path.Plan, in order
backend/internal/domain/membership.go— addTenantID uuid.UUID gorm:"type:uuid;index;not null"toPersonalAccessToken.backend/internal/infrastructure/database/prepare.go— newpreparePersonalAccessTokenTenant, run before AutoMigrate:ADD COLUMN IF NOT EXISTS tenant_id uuid, backfilltenant_id = users.default_org_id, which is exactly whatresolveSessionClaimsresolves today, so behaviour is unchanged. Rows whose owner has no default org never authenticated (the resolver errors), so they can be dropped. Mirror this inmigrations/0066_pat_tenant.up/down.sql.internal/infrastructure/repository/interfaces.go, the GORM PAT repo,internal/auth/pat.go):CreateToken(userID, tenantID, …), list/get/revoke/last-used filter onuser_id AND tenant_id.GetByTokenHashstays unscoped by design (it is the authentication step), and the returned row carries the tenant. Note this insecurity/isolation/registry.go.orsk_<8hex>_<64hex>(criterion 3).ValidateTokenandlooksLikePATstrip an optionalorsk_so legacy<8hex>_<secret>tokens keep working.PATMiddleware: resolve with the org resolver (resolveSessionForOrg(uid, pat.TenantID)), not the default-org one. A token from tenant A can then never act in tenant B, and a removed membership kills the token (criterion 4). A*scope means "everything the owner holds": todaypermsGrant(owner, "*")is false for a non-admin, so their full-access token would carry zero permissions.internal/service/token_service.go(+test),internal/middleware/tokenauth.go(+test),internal/handler/token_handler.go(+test, +token_flow_integration_test.go),internal/domain/api_token.go(+test),tokenServicewiringmain.go:278and routesmain.go:2093-2105,&domain.APIToken{}/"api_tokens"ininternal/handler/test_helpers.go,"APIToken": "#709"incmd/server/schema_test.go,/tokensentries ininternal/handler/authz_route_coverage_test.go:83,154,155,183-187andinternal/security/isolation/registry.go:254,601.frontend/src/features/settings/adminData.ts:useTokens→GET/POST /auth/pat({name, scopes:["*"]}), revoke →DELETE /auth/pat/:id; response type{ token: string }.SettingsScreen.tsxTokensTab(≈l.409-427): show the secret once in a reveal panel with a Copy button, toast "copied" only whenwriteTextresolves (criterion 6), and drop the deadrevokedfacet (PAT revoke is a hard delete).*gorm.DBand authenticates with it (criterion 2), an end-to-end middleware test asserting 200 with the token / 401 without (criterion 1), a cross-tenant test (criterion 4), and a vitest test asserting the clipboard receives a value startingorsk_, never[object Object](criterion 3).Verified — nothing yet; no code changed.
Criteria — 1 ❌ · 2 ❌ · 3 ❌ · 4 ❌ · 5 ❌ · 6 ❌ (not started, see Blocked on)
Next — Start at plan step 1 on the branch above.
Blocked on — @owner: the local auto-mode classifier refused
git rmof the legacy token files and then a one-field edit tointernal/domain/membership.go. The owner needs to allow these edits/deletions (or run step 6's deletions by hand) before the work can continue.feat(auth): bind personal access tokens to the tenant they were minte…
refactor(auth): remove the in-memory API token service (#782)
fix(settings): create API tokens on /auth/pat and show the secret once (
docs(api): document personal access tokens on /auth/pat (#782)
alex-dembele commentedon Oct 1, 2026
work — 2026-10-01
Done — PR #853, four commits on
782-fixauth-….backend/internal/domain/membership.go:PersonalAccessToken.TenantID(not null). Backfill fromusers.default_org_idininternal/infrastructure/database/prepare.go(backfillPersonalAccessTokenTenant, runs before AutoMigrate) andmigrations/0067_personal_access_tokens_tenant.{up,down}.sql. 0066 is taken by security(auth): a TOTP code can be replayed within its validity window #849.internal/infrastructure/repository/{interfaces.go,gorm_personal_access_token_repository.go}:ListByOwner,UpdateLastUsedandDeleteByOwnerfiltertenant_id AND user_id.GetByTokenHashis unscoped (the authentication step).internal/auth/pat.go:orsk_<8-hex>_<secret>(legacy form still accepted),CreateToken(user, tenant, …), typed errors.internal/middleware/pat_middleware.go: resolves with the org resolver forpat.TenantID(cmd/server/main.gopassesresolveSessionForOrg). The"*"scope means the owner's permissions.TokenService,tokenauth.go,token_handler.go,domain/api_token.goand their tests, the/tokensroutes, and the matching entries in the authz coverage list, isolation registry and schema test.features/settings/ApiTokensPanel.tsx(extracted fromSettingsScreen.tsx) on/auth/pat, one-time reveal panel, "copied" only whenwriteTextresolves, optimistic revoke, zod name check.adminData.tshooks retyped.docs/API_SECURITY_GUIDE.md,docs/API_COMPLETE_ENDPOINTS.md.Verified
go vet ./...clean;go test ./...with Postgres gives 79 ok, 0 FAIL.TestPAT_AuthenticatesProtectedRoute,TestPAT_CrossTenant,TestPATRepo_Postgres_SurvivesRestart,TestPATRepo_TenantIsolationandTestBackfillPersonalAccessTokenTenant_Postgrespass. Sabotage of the tenant predicate makes the isolation test fail.vitest apiTokensPanel.test.tsx: 5/5. Sabotage of the secret handling makes the[object Object]test fail.Criteria — 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ (unit + Postgres; no live two-org boot) · 5 ✅ · 6 ✅
Next — Owner review of PR #853. Separately, master's Settings route crashes because
src/shared/ds/index.tslostexport { useSuccessFeedback }in merge e074589. That needs its own issue; it also explains 19 failing frontend tests on master.Blocked on — nothing
alex-dembele commentedon Oct 1, 2026
work — 2026-10-01 (follow-up)
Done — Live two-organization proof for criterion 4, posted on PR #853 (#853 (comment)). A token minted in org A keeps reading A only after its owner joins B and B becomes their default org. Once the owner's membership in A is revoked, the token gets 401 and does not fall back to B. Also opened #854 for the Settings crash on master.
Verified — the outputs are in the PR comment; the run used a throwaway Postgres 18 database, now stopped.
Criteria — 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ (unit, Postgres and live) · 5 ✅ · 6 ✅
Next — Owner review of PR #853.
Blocked on — nothing
Merge pull request #853 from opendefender/782-fixauth-les-jetons-api-…