RBAC avec Claude Code : authentification, autorisation, tenants, tests et audit
RBAC fiable avec Claude Code : permissions, tenants, middleware Express, tests de refus, logs d'audit et passage vers ABAC.
Un système RBAC n’est pas sûr simplement parce qu’il contient des rôles appelés admin, editor et viewer. Les incidents viennent souvent de détails plus discrets : faire confiance à tout utilisateur connecté, oublier un filtre tenant_id dans une requête, masquer un bouton côté interface sans protéger l’API, ou laisser un éditeur modifier un objet qui ne lui appartient pas grâce à un ID deviné.
Ce guide transforme RBAC en tâche claire pour Claude Code. Nous séparons authentification et autorisation, modélisons role / permission / resource / action, appliquons deny by default, protégeons les limites de tenant, ajoutons l’autorisation au niveau de l’objet, un middleware Express, un schéma de base de données, des tests, des logs d’audit et le seuil à partir duquel RBAC doit évoluer vers ABAC. Pour la couche de connexion, voir le guide JWT avec Claude Code. Pour la sécurité opérationnelle, compléter avec les bonnes pratiques de sécurité Claude Code.
Les références externes utiles sont OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC et Auth0 Core RBAC. OWASP insiste sur le moindre privilège, le refus par défaut, la validation à chaque requête, les logs et les tests d’autorisation.
Authentification et autorisation
L’authentification répond à la question « qui est cet utilisateur ? ». Mot de passe, SSO, JWT, cookie de session et MFA appartiennent à cette couche. L’autorisation répond à la question « cet acteur peut-il exécuter cette action sur cette ressource ? ». Être connecté ne doit pas suffire pour supprimer une facture, inviter un administrateur ou lire les données d’un autre client.
Dans RBAC, gardez quatre notions explicites :
| Élément | Exemple | Question de revue |
|---|---|---|
| role | viewer, editor, billing_admin, owner | Est-ce fondé sur une responsabilité réelle |
| permission | article:update, invoice:read | Est-ce défini à un seul endroit |
| resource | article, invoice, user | Quel objet protège-t-on |
| action | read, create, update, delete | Est-ce plus précis que la méthode HTTP |
L’erreur classique consiste à créer un énorme admin, puis à empiler des exceptions. En revue, il devient difficile de savoir si une exception est une exigence produit ou un contournement temporaire. Mieux vaut définir les permissions d’abord et considérer les rôles comme des paquets de permissions.
flowchart TD
A["Authenticated actor"] --> B["Tenant boundary"]
B --> C["Role to permission map"]
C --> D["Resource and action check"]
D --> E["Object-level rule"]
E --> F["Allow"]
B --> X["Deny and audit"]
C --> X
D --> X
E --> X
Commencer par deny by default
Deny by default signifie que toute opération est refusée si le code ne l’autorise pas explicitement. Quand vous demandez à Claude Code de modifier l’autorisation, écrivez d’abord les règles de refus : permission inconnue, liste de rôles vide, tenant différent, ressource absente et propriétaire non correspondant doivent échouer de manière fermée.
L’exemple Express ci-dessous est copiable dans un nouveau projet. La démo lit l’identité depuis des headers pour faciliter les tests. En production, remplacez cette partie par un JWT ou une session déjà vérifiés.
{
"name": "rbac-express-demo",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"test": "vitest run"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.15.0",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
Le coeur RBAC en TypeScript
Ne dispersez pas les tests de rôles dans tous les controllers. Centralisez la décision dans authorize et faites passer aux routes un nom de permission. La surface de revue devient petite, et Claude Code sait exactement où intervenir quand une nouvelle ressource apparaît.
// src/rbac.ts
export const permissions = [
"project:read",
"article:read",
"article:create",
"article:update",
"article:delete",
"invoice:read",
"invoice:refund",
"user:manage"
] as const;
export type Permission = (typeof permissions)[number];
export type Role = "viewer" | "editor" | "billing_admin" | "owner";
export type Actor = {
id: string;
tenantId: string;
roles: Role[];
};
export type ResourceRecord = {
id: string;
tenantId: string;
ownerId?: string;
};
export type AuthorizationDecision = {
allow: boolean;
reason: string;
};
export const rolePermissions = {
viewer: ["project:read", "article:read", "invoice:read"],
editor: ["project:read", "article:read", "article:create", "article:update"],
billing_admin: ["project:read", "invoice:read", "invoice:refund"],
owner: [...permissions]
} as const satisfies Record<Role, readonly Permission[]>;
const knownPermissions = new Set<Permission>(permissions);
export function authorize(
actor: Actor,
permission: Permission,
record?: ResourceRecord
): AuthorizationDecision {
if (!knownPermissions.has(permission)) {
return { allow: false, reason: "unknown_permission" };
}
if (actor.roles.length === 0) {
return { allow: false, reason: "no_role" };
}
if (record && record.tenantId !== actor.tenantId) {
return { allow: false, reason: "tenant_mismatch" };
}
const roleAllows = actor.roles.some((role) =>
rolePermissions[role].includes(permission)
);
if (!roleAllows) {
return { allow: false, reason: "role_missing_permission" };
}
if (
permission === "article:update" &&
!actor.roles.includes("owner") &&
record?.ownerId !== actor.id
) {
return { allow: false, reason: "not_resource_owner" };
}
return { allow: true, reason: "allowed" };
}
export function auditAuthorization(input: {
actor?: Actor;
permission: Permission;
resourceId?: string;
decision: AuthorizationDecision;
}) {
console.info(
JSON.stringify({
type: "authorization",
actorId: input.actor?.id ?? "anonymous",
tenantId: input.actor?.tenantId ?? "unknown",
permission: input.permission,
resourceId: input.resourceId ?? null,
allow: input.decision.allow,
reason: input.decision.reason,
at: new Date().toISOString()
})
);
}
La route demande article:update, elle ne demande pas seulement « l’utilisateur est-il editor ». Le rôle donne une permission, puis le code vérifie encore le tenant et le propriétaire de l’objet.
Middleware Express pour chaque requête
L’autorisation doit s’exécuter avant que le handler ne modifie les données. Un middleware rend visible chaque route protégée et évite les contrôles oubliés.
// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import {
type Actor,
type Permission,
type ResourceRecord,
type Role,
auditAuthorization,
authorize,
rolePermissions
} from "./rbac.js";
declare global {
namespace Express {
interface Request {
actor?: Actor;
}
}
}
type Article = ResourceRecord & {
title: string;
body: string;
};
const articles: Article[] = [
{ id: "a1", tenantId: "tenant-a", ownerId: "user-1", title: "Roadmap", body: "Draft" },
{ id: "a2", tenantId: "tenant-a", ownerId: "user-2", title: "Release", body: "Ready" },
{ id: "b1", tenantId: "tenant-b", ownerId: "user-9", title: "Private", body: "Secret" }
];
function parseRoles(value: string | undefined): Role[] {
return (value ?? "")
.split(",")
.map((role) => role.trim())
.filter((role): role is Role => role in rolePermissions);
}
function authenticateForDemo(req: Request, _res: Response, next: NextFunction) {
const userId = req.header("x-user-id");
const tenantId = req.header("x-tenant-id");
if (userId && tenantId) {
req.actor = {
id: userId,
tenantId,
roles: parseRoles(req.header("x-roles"))
};
}
next();
}
function findArticle(req: Request): Article | undefined {
return articles.find((article) => article.id === req.params.articleId);
}
function requirePermission(
permission: Permission,
loadResource?: (req: Request) => ResourceRecord | undefined
) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.actor) {
return res.status(401).json({ error: "unauthenticated" });
}
const record = loadResource?.(req);
if (loadResource && !record) {
return res.status(404).json({ error: "not_found" });
}
const decision = authorize(req.actor, permission, record);
auditAuthorization({
actor: req.actor,
permission,
resourceId: record?.id,
decision
});
if (!decision.allow) {
return res.status(403).json({ error: "forbidden", reason: decision.reason });
}
return next();
};
}
export const app = express();
app.use(express.json());
app.use(authenticateForDemo);
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get(
"/articles/:articleId",
requirePermission("article:read", findArticle),
(req, res) => {
res.json(findArticle(req));
}
);
app.patch(
"/articles/:articleId",
requirePermission("article:update", findArticle),
(req, res) => {
const article = findArticle(req);
if (!article) return res.status(404).json({ error: "not_found" });
article.title = String(req.body.title ?? article.title);
article.body = String(req.body.body ?? article.body);
return res.json(article);
}
);
app.delete(
"/articles/:articleId",
requirePermission("article:delete", findArticle),
(req, res) => {
const index = articles.findIndex((article) => article.id === req.params.articleId);
if (index >= 0) articles.splice(index, 1);
return res.status(204).send();
}
);
app.get("/admin/users", requirePermission("user:manage"), (_req, res) => {
res.json([{ id: "user-1" }, { id: "user-2" }]);
});
if (process.env.NODE_ENV !== "test") {
app.listen(3000, () => {
console.log("RBAC demo listening on http://localhost:3000");
});
}
En production, remplacez authenticateForDemo par une identité vérifiée. Même si un fournisseur d’identité envoie des claims de rôles ou de permissions, l’API doit revérifier le tenant et les règles d’objet côté serveur.
Schéma de données et limite de tenant
Une faille d’autorisation peut apparaître avant le middleware : une requête récupère la mauvaise ligne, puis le handler la considère sûre. Le schéma doit donc rendre visibles tenant_id, les contraintes uniques composées et les logs d’audit.
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
UNIQUE (tenant_id, email)
);
CREATE TABLE roles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
UNIQUE (resource, action)
);
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role_id TEXT NOT NULL REFERENCES roles(id),
tenant_id TEXT NOT NULL REFERENCES tenants(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id),
permission_id TEXT NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE articles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE authorization_audit_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT,
actor_id TEXT,
permission TEXT NOT NULL,
resource_id TEXT,
allowed BOOLEAN NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_tenant_owner ON articles(tenant_id, owner_id);
CREATE INDEX idx_audit_tenant_created ON authorization_audit_logs(tenant_id, created_at);
Dans un SaaS B2B, les rôles personnalisés par tenant sont fréquents, donc tenant_id + name est souvent le bon unique. Dans un contexte très réglementé, des rôles globaux fixes avec des affectations locales peuvent être plus simples à auditer.
Tester les refus
Les tests doivent prouver que les cas autorisés marchent, mais ils doivent surtout couvrir les refus : même rôle dans un autre tenant, même tenant mais autre propriétaire, utilisateur connecté sans permission, ressource absente et requête non authentifiée.
// test/rbac.test.ts
import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/server.js";
const editorUser1 = {
"x-user-id": "user-1",
"x-tenant-id": "tenant-a",
"x-roles": "editor"
};
const editorTenantB = {
"x-user-id": "user-9",
"x-tenant-id": "tenant-b",
"x-roles": "editor"
};
const owner = {
"x-user-id": "owner-1",
"x-tenant-id": "tenant-a",
"x-roles": "owner"
};
describe("RBAC middleware", () => {
it("rejects unauthenticated requests", async () => {
const res = await request(app).get("/articles/a1");
expect(res.status).toBe(401);
});
it("allows an editor to update an owned article in the same tenant", async () => {
const res = await request(app)
.patch("/articles/a1")
.set(editorUser1)
.send({ title: "Updated roadmap" });
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated roadmap");
});
it("blocks cross-tenant access even when the role matches", async () => {
const res = await request(app).get("/articles/a1").set(editorTenantB);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("tenant_mismatch");
});
it("blocks editors from managing users", async () => {
const res = await request(app).get("/admin/users").set(editorUser1);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("role_missing_permission");
});
it("allows owners to delete tenant resources", async () => {
const res = await request(app).delete("/articles/a2").set(owner);
expect(res.status).toBe(204);
});
});
npm install
npm test
npm run dev
Quand Claude Code ajoute des tests, demandez explicitement tenant_mismatch, not_resource_owner, role_missing_permission et unauthenticated. Le reason renvoyé au client doit correspondre au reason écrit dans le log d’audit.
Quatre cas d’usage
Premier cas : gestion de projets B2B. Le owner invite des membres et gère la facturation ; l’editor modifie seulement le contenu. Le tenant est plus important que le nom du rôle : un projectId deviné doit échouer dans la requête et dans le middleware.
Deuxième cas : CMS interne. Un éditeur modifie ses brouillons, mais supprimer un article publié ou modifier celui d’un autre auteur exige un rôle plus fort. C’est une autorisation par objet au-dessus de RBAC, car article:update dépend aussi de ownerId et du statut.
Troisième cas : facturation et remboursements. billing_admin peut lire les factures, mais un remboursement peut dépendre du montant, de l’état d’approbation ou d’un second validateur. RBAC ouvre la porte ; les attributs métier décident si l’action précise est sûre.
Quatrième cas : impersonation support. Le support peut voir un espace client pour diagnostiquer un ticket, mais ne doit pas changer l’email, le moyen de paiement ou le rôle owner. Chaque action impersonée doit être auditée séparément.
Échecs concrets et corrections
| Échec | Risque | Correction |
|---|---|---|
| Retourner tous les enregistrements à tout utilisateur connecté | Escalade horizontale | Filtrer par tenant_id et ID de ressource |
| Masquer seulement les boutons côté UI | L’appel API direct passe encore | Refuser côté serveur |
Un admin géant | La revue des droits devient inutile | Découper les permissions |
| Logger seulement les actions autorisées | Les tentatives disparaissent | Logger allow et deny avec reason |
| Tester seulement les succès | Les régressions ouvrent des failles | Automatiser la matrice des refus |
L’interface n’est pas une frontière de sécurité. Cacher un bouton améliore l’expérience, mais la protection réelle doit être dans l’API.
Ce qu’il faut déléguer à Claude Code
Claude Code est utile pour traduire une politique claire en code, tests et diff relisible. Il ne doit pas inventer la politique. Donnez-lui les fichiers, les règles de refus et les critères de revue.
Help implement RBAC.
Only change src/rbac.ts, src/server.ts, and test/rbac.test.ts.
Requirements:
- An authenticated actor has userId, tenantId, and roles
- Permissions use the resource:action format
- Unknown permission, empty role list, and tenant mismatch deny by default
- article:update is allowed only for owner role or the article owner
- 403 responses include a reason, and audit logs use the same reason
- Add more denial tests than success tests
Do not:
- Add an admin bypass
- Treat UI visibility as authorization
- Modify the existing authentication flow
En revue, vérifiez que authorize est le seul point de décision, que chaque route protégée passe par le middleware, que les requêtes objet portent une limite de tenant, que les logs n’exposent ni token ni secret, et que les tests couvrent les refus. Si Claude Code produit un gros diff, séparez l’autorisation des refactorings non liés.
Quand passer à ABAC
RBAC est bon pour représenter des responsabilités. Il devient lourd quand les règles dépendent d’attributs : remboursement au-dessus d’un seuil avec double validation, données personnelles européennes vues seulement depuis une région UE, export d’audit réservé au plan Enterprise, ou permission valable uniquement en heures ouvrées.
ABAC signifie Attribute-Based Access Control, contrôle d’accès basé sur les attributs. Il évalue les attributs de l’acteur, de la ressource, de l’environnement et de la requête. En pratique, gardez RBAC comme porte principale et ajoutez des règles ABAC là où les règles métier l’exigent. Même avec un moteur de politiques, gardez d’abord des permissions claires, des limites de tenant, des logs et des tests.
Conclusion
Un bon RBAC se reconnaît à ses refus clairs. Séparez authentification et autorisation, exprimez les permissions en resource:action, appliquez deny by default, vérifiez les tenants, ajoutez les règles par objet et prouvez le tout par des tests et des logs. Cette structure donne aussi à Claude Code un périmètre sûr : implémenter la politique, ajouter des tests de refus et garder le changement vérifiable.
Pour un flux API complet, combinez ce guide avec le guide de développement API Claude Code, le guide JWT et les bonnes pratiques de sécurité. Si vous cherchez conseil, formation ou aide d’implémentation, préparez votre table actuelle de rôles, la liste des API protégées et les opérations les plus dangereuses si elles étaient exposées.
Lorsque vous testez cette approche, vérifiez quatre points : les tests de refus passent, les IDs d’un autre tenant renvoient 403, les logs contiennent allow et deny, et Claude Code n’a pas ajouté de raccourci qui contourne authorize. Commencez par une opération risquée, écrivez role, permission, resource, action, tenantId et ownerId, puis transformez cette matrice en code.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Permission receipt Claude Code : portée, preuves et rollback
Modèle de permission receipt pour Claude Code : actions autorisées, limites d'approbation, commandes de preuve, rollback et CTAs revenus.
Agent Harness securise pour Claude Code et Codex : permissions, verification et rollback
Construisez un Agent Harness pratique pour Claude Code et Codex avec politiques, plan, verification et recuperation.
Sous-agents Claude Code : guide pratique pour déléguer sans perdre le contrôle
Guide pratique des sous-agents Claude Code pour répartir articles et code : règles, prompts, pièges et checklist.