Advanced (Aktualisiert: 1.6.2026)

RBAC mit Claude Code: Authentifizierung, Autorisierung, Tenants, Tests und Audit

RBAC sicher mit Claude Code umsetzen: Permissions, Tenant-Grenzen, Express-Middleware, Tests, Audit-Logs und der Übergang zu ABAC.

RBAC mit Claude Code: Authentifizierung, Autorisierung, Tenants, Tests und Audit

RBAC ist nicht automatisch sicher, nur weil eine Anwendung Rollen wie admin, editor und viewer besitzt. Viele Autorisierungsfehler entstehen an kleineren Stellen: Ein eingeloggter Nutzer wird als berechtigt behandelt, eine Abfrage vergisst den tenant_id-Filter, ein Button wird nur im UI versteckt, oder ein Editor darf mit einer geratenen ID fremde Objekte ändern.

Dieser Leitfaden macht RBAC zu einer klar prüfbaren Aufgabe für Claude Code. Wir trennen Authentifizierung und Autorisierung, modellieren role / permission / resource / action, erzwingen deny by default, schützen Tenant-Grenzen, ergänzen Objekt-Autorisierung, Express-Middleware, Datenbankschema, Tests, Audit-Logs und die Grenze, ab der ABAC sinnvoll wird. Für die Login-Schicht passt der Claude Code JWT-Authentifizierungsleitfaden. Für den breiteren Schutz siehe die Claude Code Security Best Practices.

Als externe Grundlage dienen OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC und Auth0 Core RBAC. OWASP betont minimale Rechte, Ablehnung als Standard, Prüfung jeder geschützten Anfrage, Logging und Autorisierungstests.

Authentifizierung ist nicht Autorisierung

Authentifizierung beantwortet die Frage: Wer ist dieser Nutzer? Passwort, SSO, JWT, Session-Cookie und MFA gehören hierher. Autorisierung beantwortet: Darf dieser Akteur diese Aktion auf dieser Ressource ausführen? Ein Login erlaubt nicht automatisch das Löschen einer Rechnung, das Einladen eines Administrators oder das Lesen fremder Kundendaten.

Für RBAC sollten vier Begriffe immer sichtbar bleiben:

ElementBeispielReview-Frage
roleviewer, editor, billing_admin, ownerBeruht die Rolle auf Verantwortung
permissionarticle:update, invoice:readIst sie zentral definiert
resourcearticle, invoice, userWelches Objekt wird geschützt
actionread, create, update, deleteIst sie präziser als die HTTP-Methode

Der typische Fehler ist eine riesige admin-Rolle, der später Ausnahmen hinzugefügt werden. Im Review ist dann unklar, ob eine Ausnahme fachlich gewollt oder ein temporärer Umweg ist. Stabiler ist es, zuerst Permissions zu definieren und Rollen als Pakete aus Permissions zu behandeln.

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

Mit deny by default beginnen

Deny by default bedeutet: Jede Operation wird abgelehnt, wenn der Code sie nicht ausdrücklich erlaubt. Wenn Claude Code an Autorisierung arbeiten soll, sollten die Ablehnungsregeln zuerst im Auftrag stehen: unbekannte Permission, leere Rollenliste, anderer Tenant, fehlende Ressource und falscher Besitzer müssen geschlossen fehlschlagen.

Das folgende Express-Beispiel kann in ein neues Projekt kopiert werden. Die Demo liest Identität aus Headern, damit Tests einfach bleiben. In Produktion ersetzt du diese Schicht durch verifiziertes JWT- oder Session-Material.

{
  "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"]
}

Der RBAC-Kern in TypeScript

Rollenchecks sollten nicht in allen Controllern verstreut sein. Die Entscheidung gehört in eine Funktion authorize; Routen übergeben nur den Permission-Namen. Dadurch bleibt die Review-Fläche klein, und Claude Code hat einen klaren Ort für Änderungen.

// 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()
    })
  );
}

Die Route fragt also nach article:update, nicht nur nach “ist der Nutzer editor”. Die Rolle liefert eine Permission, danach prüfen Tenant-Grenze und Besitzerregel weiter.

Express-Middleware für jede Anfrage

Autorisierung muss laufen, bevor ein Handler Daten verändert. Middleware macht sichtbar, welche Routen geschützt sind, und verhindert vergessene Checks.

// 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");
  });
}

In Produktion ersetzt du authenticateForDemo durch eine verifizierte Identität. Selbst wenn ein Identity Provider Rollen oder Permissions liefert, muss die API Tenant- und Objektregeln serverseitig erneut prüfen.

Datenbankschema mit Tenant-Grenze

Viele Autorisierungsfehler passieren vor der Middleware: Eine Abfrage lädt die falsche Zeile, und der Handler verarbeitet sie. Deshalb sollten tenant_id, zusammengesetzte Unique-Constraints und Audit-Logs im Schema sichtbar sein.

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);

In B2B-SaaS sind tenant-spezifische Rollen häufig, daher ist tenant_id + name oft sinnvoll. In regulierten Umgebungen sind globale Rollendefinitionen mit lokalen Zuweisungen manchmal leichter zu auditieren.

Ablehnungen testen

Tests müssen erlaubte Fälle bestätigen, aber sie sollten vor allem Ablehnungen beweisen: gleiche Rolle in anderem Tenant, gleicher Tenant aber anderer Besitzer, eingeloggter Nutzer ohne Permission, fehlende Ressource und nicht authentifizierte Anfrage.

// 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

Wenn Claude Code Tests ergänzt, nenne tenant_mismatch, not_resource_owner, role_missing_permission und unauthenticated explizit. Der Reason in der Antwort sollte mit dem Reason im Audit-Log übereinstimmen.

Vier Praxisfälle

Der erste Fall ist B2B-Projektmanagement. Owner laden Mitglieder ein und ändern Billing; Editor bearbeiten Inhalte. Die Tenant-Grenze ist wichtiger als der Rollenname: Eine geratene projectId muss in Query und Middleware scheitern.

Der zweite Fall ist ein internes CMS. Ein Editor darf eigene Entwürfe bearbeiten, aber veröffentlichte Artikel löschen oder fremde Artikel ändern erfordert eine stärkere Rolle. Das ist Objekt-Autorisierung über RBAC, weil article:update auch ownerId und Status benötigt.

Der dritte Fall ist Billing und Erstattung. billing_admin darf Rechnungen lesen, aber eine Erstattung kann von Betrag, Freigabestatus oder Vier-Augen-Prinzip abhängen. RBAC öffnet die Tür; Geschäftsattribute entscheiden über die konkrete Aktion.

Der vierte Fall ist Support-Impersonation. Support darf einen Kunden-Workspace ansehen, aber keine E-Mail, Zahlungsmethode oder Owner-Rolle ändern. Jede impersonierte Aktion muss getrennt vom Kunden selbst auditiert werden.

Konkrete Fehler und Korrekturen

FehlerRisikoKorrektur
Alle Records an jeden eingeloggten Nutzer zurückgebenHorizontale RechteausweitungImmer nach tenant_id und Resource-ID filtern
Buttons nur im UI versteckenDirekte API-Aufrufe funktionieren weiterServer-Middleware erzwingen
Eine riesige admin-RolleRechte-Review wird wertlosPermissions zerlegen
Nur erlaubte Aktionen loggenAngriffsversuche verschwindenAllow und deny mit Reason loggen
Nur Erfolg testenRegressionen öffnen leise LückenAblehnungsmatrix automatisieren

Das UI ist keine Sicherheitsgrenze. Ein versteckter Button verbessert UX, schützt aber keinen Endpoint. Die echte Grenze liegt auf dem Server.

Was Claude Code übernehmen sollte

Claude Code ist gut darin, eine klare Policy in Code, Tests und prüfbare Diffs zu übersetzen. Es sollte die Sicherheits-Policy nicht selbst erfinden. Gib Dateien, Ablehnungsregeln und Review-Kriterien vor.

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

Im Review prüfst du, ob authorize der einzige Entscheidungspunkt ist, jede geschützte Route Middleware nutzt, Objektabfragen Tenant-Grenzen enthalten, Logs keine Tokens oder Secrets ausgeben und Tests negative Fälle abdecken. Bei großen Diffs sollten Autorisierung und unabhängige Refactorings getrennt werden.

Wann RBAC zu ABAC wird

RBAC eignet sich für berufliche Zuständigkeiten. Es wird schwerfällig, wenn Regeln von Attributen abhängen: Erstattungen über einer Schwelle brauchen doppelte Freigabe, EU-Personendaten dürfen nur aus einer EU-Region gelesen werden, Audit-Export ist nur im Enterprise-Plan erlaubt, oder eine Permission gilt nur während Geschäftszeiten.

ABAC bedeutet Attribute-Based Access Control, also attributbasierte Zugriffskontrolle. Es bewertet Attribute von Akteur, Ressource, Umgebung und Anfrage. Praktisch bleibt RBAC das grobe Tor, während ABAC-Regeln dort ergänzt werden, wo Fachlogik es verlangt. Auch mit Policy-Engines sollten Permission-Namen, Tenant-Grenzen, Audit-Logs und Tests vorher klar sein.

Fazit

Gutes RBAC erkennt man an klaren Ablehnungen. Trenne Authentifizierung von Autorisierung, definiere Permissions als resource:action, nutze deny by default, prüfe Tenant-Grenzen, ergänze Objektregeln und verifiziere alles mit Tests und Logs. Diese Struktur gibt Claude Code einen sicheren Rahmen: Policy umsetzen, negative Tests ergänzen und Änderungen reviewbar halten.

Für einen vollständigen API-Fluss kombiniere diesen Artikel mit dem Claude Code API Development Guide, dem JWT Authentication Guide und den Security Best Practices. Für Beratung, Training oder Implementierungshilfe sind aktuelle Rollentabelle, geschützte API-Liste und besonders riskante Operationen die beste Vorbereitung.

Beim Ausprobieren prüfe vier Punkte: Ablehnungstests bestehen, IDs aus anderem Tenant liefern 403, Audit-Logs enthalten allow und deny, und Claude Code hat keinen Shortcut eingebaut, der authorize umgeht. Beginne mit einer riskanten Operation und notiere role, permission, resource, action, tenantId und ownerId, bevor du es in Code überführst.

#Claude Code #RBAC #authorization #security #design patterns
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.