Zum Inhalt springen

API-Autorisierungsmuster: RBAC, ABAC und Policy-as-Code

Robuste API-Autorisierung mit RBAC, ABAC und Policy-as-Code — schützt Ressourcen auf jeder Ebene und bleibt wartbar, während das System wächst.

4 Min. Lesezeit
Mehrschichtige Autorisierungsarchitektur, die zeigt, wie eine Anfrage durch Authentifizierung, Rollenprüfungen, Attributrichtlinien und Berechtigungen auf Ressourcenebene fließt

Authentifizierung beantwortet die Frage „Wer bist du?". Autorisierung beantwortet die Frage „Was darfst du tun?". Die meisten APIs lösen die Authentifizierung sauber mit JWT oder OAuth, implementieren die Autorisierung aber als verstreute if-Anweisungen quer durch die Codebasis. Wenn eine neue Rolle Zugriff auf einen bestehenden Endpunkt braucht, spielen die Entwickler Whack-a-Mole über Dutzende von Dateien hinweg. Das Zentralisieren der Autorisierungslogik in einer kohärenten Policy-Engine verhindert diesen Verfall.

Das Problem mit Inline-Autorisierung

Autorisierungslogik beginnt meist einfach und wächst zu einem nicht mehr wartbaren Durcheinander.

tstypescript
// ❌ Authorization scattered across handlers
async function deleteProject(req: Request, res: Response) {
  const user = req.user;
  const project = await db.projects.findById(req.params.id);
 
  // First check: is this their project?
  if (project.ownerId !== user.id) {
    // Wait, admins can delete anything
    if (user.role !== "admin") {
      // Actually, org admins can delete within their org
      if (
        user.role !== "org_admin" ||
        project.orgId !== user.orgId
      ) {
        // Team leads can delete in their team's projects
        if (
          user.role !== "team_lead" ||
          !user.teamIds.includes(project.teamId)
        ) {
          return res.status(403).json({ error: "Forbidden" });
        }
      }
    }
  }
 
  // Duplicate logic exists in updateProject, archiveProject,
  // transferProject, and every new endpoint touching projects
  await db.projects.delete(project.id);
  return res.json({ success: true });
}
tstypescript
// ✅ Centralized authorization with clear policy
async function deleteProject(req: Request, res: Response) {
  const project = await db.projects.findById(req.params.id);
 
  const allowed = await authorize({
    subject: req.user,
    action: "delete",
    resource: project,
    resourceType: "project",
  });
 
  if (!allowed) {
    return res.status(403).json({ error: "Forbidden" });
  }
 
  await db.projects.delete(project.id);
  return res.json({ success: true });
}

Die zentralisierte Version ist lesbar, testbar und wartbar. Die Policy-Definition liegt an einem einzigen Ort, und jeder Handler delegiert an sie.

Role-Based Access Control (RBAC)

RBAC weist Berechtigungen Rollen zu und ordnet die Rollen dann den Benutzern zu. Es funktioniert gut in Systemen mit klar definierten Benutzerkategorien, stößt aber bei kontextabhängigen Berechtigungen an Grenzen.

tstypescript
interface Role {
  name: string;
  permissions: Permission[];
}
 
interface Permission {
  resource: string;
  actions: string[];
}
 
const roles: Role[] = [
  {
    name: "viewer",
    permissions: [
      { resource: "project", actions: ["read", "list"] },
      { resource: "document", actions: ["read", "list"] },
    ],
  },
  {
    name: "editor",
    permissions: [
      { resource: "project", actions: ["read", "list", "update"] },
      { resource: "document", actions: ["read", "list", "create", "update"] },
    ],
  },
  {
    name: "admin",
    permissions: [
      { resource: "project", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "document", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "user", actions: ["read", "list", "create", "update", "delete"] },
    ],
  },
];
 
class RBACEngine {
  private roleMap: Map<string, Role>;
 
  constructor(roles: Role[]) {
    this.roleMap = new Map(roles.map(r => [r.name, r]));
  }
 
  check(
    userRoles: string[],
    resource: string,
    action: string
  ): boolean {
    for (const roleName of userRoles) {
      const role = this.roleMap.get(roleName);
      if (!role) continue;
 
      const permission = role.permissions.find(
        p => p.resource === resource
      );
      if (permission && permission.actions.includes(action)) {
        return true;
      }
    }
    return false;
  }
}
 
const rbac = new RBACEngine(roles);
rbac.check(["editor"], "document", "create");  // true
rbac.check(["viewer"], "document", "delete");   // false

RBAC ist einfach, erreicht aber seine Grenzen, wenn Berechtigungen von Ressourcenbesitz, Teammitgliedschaft oder anderen kontextuellen Attributen abhängen.

Attribute-Based Access Control (ABAC)

ABAC wertet Policies anhand von Attributen des Subjekts, der Ressource, der Aktion und der Umgebung aus. Damit lassen sich kontextabhängige Berechtigungen abbilden, die RBAC nicht ausdrücken kann.

tstypescript
interface PolicyRule {
  id: string;
  description: string;
  effect: "allow" | "deny";
  condition: (ctx: AuthorizationContext) => boolean;
}
 
interface AuthorizationContext {
  subject: {
    id: string;
    roles: string[];
    orgId: string;
    teamIds: string[];
    department: string;
  };
  action: string;
  resource: {
    type: string;
    ownerId: string;
    orgId: string;
    teamId: string;
    sensitivity: "public" | "internal" | "confidential";
    status: string;
  };
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
const policies: PolicyRule[] = [
  {
    id: "owner-full-access",
    description: "Resource owners can do anything with their resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.id === ctx.resource.ownerId,
  },
  {
    id: "team-member-read-write",
    description: "Team members can read and update team resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.teamIds.includes(ctx.resource.teamId) &&
      ["read", "update", "list"].includes(ctx.action),
  },
  {
    id: "confidential-requires-mfa",
    description: "Confidential resources require MFA verification",
    effect: "deny",
    condition: (ctx) =>
      ctx.resource.sensitivity === "confidential" &&
      !ctx.environment.mfaVerified,
  },
  {
    id: "no-delete-archived",
    description: "Cannot delete archived resources",
    effect: "deny",
    condition: (ctx) =>
      ctx.action === "delete" &&
      ctx.resource.status === "archived",
  },
];
 
function evaluatePolicies(
  policies: PolicyRule[],
  ctx: AuthorizationContext
): boolean {
  // Deny rules take priority
  for (const policy of policies) {
    if (policy.effect === "deny" && policy.condition(ctx)) {
      return false;
    }
  }
 
  // Check for at least one allow
  for (const policy of policies) {
    if (policy.effect === "allow" && policy.condition(ctx)) {
      return true;
    }
  }
 
  // Default deny
  return false;
}

Die Auswertungsstrategie Deny-Overrides bedeutet, dass Schutzregeln unabhängig von der Reihenfolge immer gewinnen. Das verhindert versehentliche Zugriffsgewährungen, wenn neue Allow-Policies hinzukommen.

Durchsetzung auf Middleware-Ebene

Die Autorisierung sollte auf der Middleware-Ebene durchgesetzt werden, damit einzelne Handler Berechtigungsprüfungen nicht versehentlich überspringen können.

tstypescript
interface ResourceResolver {
  resolve: (req: Request) => Promise<AuthorizableResource>;
}
 
interface AuthorizableResource {
  type: string;
  ownerId: string;
  orgId: string;
  teamId: string;
  sensitivity: string;
  status: string;
}
 
function requirePermission(
  action: string,
  resourceResolver: ResourceResolver
) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const resource = await resourceResolver.resolve(req);
 
    const ctx: AuthorizationContext = {
      subject: {
        id: req.user.id,
        roles: req.user.roles,
        orgId: req.user.orgId,
        teamIds: req.user.teamIds,
        department: req.user.department,
      },
      action,
      resource: {
        type: resource.type,
        ownerId: resource.ownerId,
        orgId: resource.orgId,
        teamId: resource.teamId,
        sensitivity: resource.sensitivity,
        status: resource.status,
      },
      environment: {
        ipAddress: req.ip ?? "unknown",
        time: new Date(),
        mfaVerified: req.user.mfaVerified ?? false,
      },
    };
 
    const allowed = evaluatePolicies(policies, ctx);
 
    if (!allowed) {
      return res.status(403).json({
        error: "Insufficient permissions",
        action,
        resource: resource.type,
      });
    }
 
    next();
  };
}
 
// Usage in routes
const projectResolver: ResourceResolver = {
  resolve: async (req) => {
    const project = await db.projects.findById(req.params.id);
    return {
      type: "project",
      ownerId: project.ownerId,
      orgId: project.orgId,
      teamId: project.teamId,
      sensitivity: project.sensitivity,
      status: project.status,
    };
  },
};
 
router.delete(
  "/projects/:id",
  requirePermission("delete", projectResolver),
  deleteProjectHandler
);

Autorisierungsrichtlinien testen

Die Autorisierungslogik ist kritisch genug, um umfassende Tests zu verdienen. Policy-Tests sollten jede Rolle-Ressource-Aktion-Kombination sowie Randfälle abdecken.

tstypescript
describe("Authorization Policies", () => {
  const baseCtx: AuthorizationContext = {
    subject: {
      id: "user-1",
      roles: ["editor"],
      orgId: "org-1",
      teamIds: ["team-1"],
      department: "engineering",
    },
    action: "read",
    resource: {
      type: "project",
      ownerId: "user-2",
      orgId: "org-1",
      teamId: "team-1",
      sensitivity: "internal",
      status: "active",
    },
    environment: {
      ipAddress: "10.0.0.1",
      time: new Date("2023-04-26T10:00:00Z"),
      mfaVerified: true,
    },
  };
 
  test("team members can read team resources", () => {
    expect(evaluatePolicies(policies, baseCtx)).toBe(true);
  });
 
  test("confidential resources denied without MFA", () => {
    const ctx = {
      ...baseCtx,
      resource: { ...baseCtx.resource, sensitivity: "confidential" },
      environment: { ...baseCtx.environment, mfaVerified: false },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
 
  test("cannot delete archived resources even as owner", () => {
    const ctx = {
      ...baseCtx,
      action: "delete",
      subject: { ...baseCtx.subject, id: "user-2" }, // is owner
      resource: { ...baseCtx.resource, ownerId: "user-2", status: "archived" },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
});

Die wichtigsten Erkenntnisse

Autorisierung ist zu wichtig, um sie auf Handler-Funktionen zu verteilen, und zu komplex, um sie allein mit einfachen Rollenprüfungen abzubilden. Beginne mit RBAC für klare Rollengrenzen und ergänze dann ABAC-Policies für kontextabhängige Regeln wie Besitzverhältnisse, Teammitgliedschaft, Vertraulichkeitsstufen und MFA-Anforderungen. Nutze die Deny-Overrides-Auswertung, damit Schutzregeln immer Vorrang haben. Setze die Autorisierung auf der Middleware-Ebene durch, damit Handler Prüfungen nicht versehentlich überspringen können. Teste Policies so gründlich wie die Geschäftslogik – jede Rolle-Aktion-Ressource-Kombination sollte einen entsprechenden Testfall haben. Das Ziel ist ein System, in dem das Hinzufügen einer neuen Berechtigungsregel bedeutet, eine einzige Policy-Datei zu bearbeiten – statt Dutzende von Route-Handlern zu durchsuchen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX