Implementing Attribute-Based Access Control (ABAC) in Your API

Team 8 min read

#security

#authorization

#abac

#api-design

Implementing Attribute-Based Access Control (ABAC) in Your API

ABAC is an authorization model that evaluates attributes about the subject, resource, action, and environment to decide whether a request should be allowed. Unlike role-based access control (RBAC), which can get unwieldy as permissions grow, ABAC stays flexible by making decisions from contextual data, not just static roles.

This guide shows how to design, implement, and operate ABAC for an API, including concrete policy examples and integration patterns.

When to Choose ABAC

  • You need fine-grained, context-aware permissions beyond simple roles.
  • You serve multiple tenants with isolation requirements.
  • You have dynamic data sensitivity, purpose limitations, or time/location constraints.
  • You want to unify authorization across services using a central policy.

Core Concepts

  • Subject: who is calling (user, service). Attributes: id, roles, org, clearance, device posture.
  • Resource: the thing being accessed (record, file, endpoint). Attributes: owner_id, tenant_id, sensitivity, tags.
  • Action: what is being done (read, write, delete, approve).
  • Environment: context of the request (time, IP, region, risk score, authentication strength).

Policy evaluates a request consisting of these attributes and returns allow or deny, optionally with obligations (extra steps the PEP must perform, like redaction).

High-Level Architecture

  • PEP (Policy Enforcement Point): where checks are executed, typically API middleware or API gateway.
  • PDP (Policy Decision Point): the engine that evaluates policies and returns decisions.
  • PIP (Policy Information Point): fetches attributes from identity, databases, or services.
  • PAP (Policy Administration Point): where policies are authored, validated, and distributed.

Common deployment patterns:

  • Sidecar PDP per service (fast, isolated).
  • Central PDP service (shared, easier to manage).
  • Gateway PDP plugin (coarse-grained pre-authorization at the edge), with in-service PEPs for fine-grained checks.

Default-deny and explicit allow are best practice.

Choosing a Policy Engine

  • Open Policy Agent (OPA) with Rego
    • Pros: mature, cloud-native, sidecar or central, bundles, decision logs, partial evaluation.
    • Use cases: API authorization, data filtering, Kubernetes admission.
  • Cedar (AWS)
    • Pros: human-friendly, strong safety guarantees, policy validation tools.
    • Use cases: fine-grained permissions in apps and AWS Verified Permissions.
  • Casbin
    • Pros: lightweight libraries across many languages, RBAC/ABAC/REST support.
    • Use cases: embed directly into services where a library approach is sufficient.
  • XACML
    • Standards-based with rich combining algorithms; heavier to operate.

Pick one engine for the platform to avoid fragmentation. OPA and Cedar are popular choices for modern APIs.

Designing Your Policy Model

  1. Define your attribute schema
  • Subject: subject.id, subject.roles, subject.tenant_id, subject.assurance_level
  • Resource: resource.type, resource.id, resource.owner_id, resource.tenant_id, resource.sensitivity, resource.tags
  • Action: action (read, update, delete, approve)
  • Environment: env.time, env.ip, env.region, env.device_trust, env.risk_score
  1. Establish naming and normalization rules
  • Single source of truth for tenants and owners.
  • Normalize user and resource tenant_id for easy comparisons.
  • Use stable types (strings, numbers, booleans, sets) and avoid free-form blobs.
  1. Choose combining and conflict strategies
  • Default deny.
  • Deny-overrides as a safe global rule.
  • Add explicit exceptions where needed and document them.
  1. Start with a small vocabulary
  • Keep policies readable. Introduce new attributes intentionally.

Example with OPA (Rego) and Express

The service acts as the PEP. It builds an authorization input from the request and resource data, calls OPA for a decision, and enforces the result.

Rego policy (authz.rego):

package authz

default allow = false

# Require tenant isolation by default
tenant_isolated {
  input.subject.tenant_id == input.resource.tenant_id
}

# Admins within the tenant can do anything
allow {
  tenant_isolated
  "admin" == input.subject.role
}

# Resource owners can read and update their own resources
allow {
  tenant_isolated
  input.subject.id == input.resource.owner_id
  input.action in {"read", "update"}
}

# Publicly visible resources allow read
allow {
  input.action == "read"
  input.resource.visibility == "public"
}

# Sensitive resources require higher assurance or device trust
allow {
  tenant_isolated
  input.action == "read"
  input.resource.sensitivity == "high"
  input.subject.assurance_level >= 2
  input.env.device_trust == true
}

Express middleware calling a local OPA sidecar:

// npm i express jsonwebtoken node-fetch
import express from "express";
import jwt from "jsonwebtoken";
import fetch from "node-fetch";

// Imagine a simple DB fetch for resource attributes
async function getResourceAttributes(type, id) {
  // Replace with real DB call
  return {
    id,
    type,
    owner_id: "user-123",
    tenant_id: "tenant-abc",
    sensitivity: "high",
    visibility: "private",
  };
}

async function authorize(req, res, next) {
  try {
    const auth = req.headers.authorization || "";
    const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
    if (!token) return res.status(401).json({ error: "missing token" });

    // Verify JWT (use your issuer's public keys)
    const payload = jwt.decode(token, { json: true }) || {};
    // In production, verify signature and claims: jwt.verify(token, key, { algorithms: [...] })

    const subject = {
      id: payload.sub,
      role: payload.role,
      tenant_id: payload.tenant_id,
      assurance_level: payload.acr ? Number(payload.acr) : 1,
    };

    const action = req.method.toLowerCase() === "get" ? "read"
                  : req.method.toLowerCase() === "post" ? "create"
                  : req.method.toLowerCase() === "put" ? "update"
                  : req.method.toLowerCase() === "patch" ? "update"
                  : req.method.toLowerCase() === "delete" ? "delete"
                  : "other";

    // Map route params to a resource
    const resourceType = "document";
    const resourceId = req.params.id;
    const resource = await getResourceAttributes(resourceType, resourceId);

    const env = {
      ip: req.ip,
      time: new Date().toISOString(),
      region: req.headers["x-region"] || "unknown",
      device_trust: req.headers["x-device-trust"] === "true",
    };

    const input = { subject, resource, action, env };

    // Ask OPA for a decision
    const opaResp = await fetch("http://localhost:8181/v1/data/authz/allow", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ input }),
    });

    if (!opaResp.ok) throw new Error("OPA error");
    const decision = await opaResp.json();

    if (decision.result === true) {
      return next();
    } else {
      return res.status(403).json({ error: "forbidden" });
    }
  } catch (e) {
    return res.status(500).json({ error: "authorization failure" });
  }
}

const app = express();
app.get("/documents/:id", authorize, (req, res) => {
  res.json({ id: req.params.id, content: "..." });
});
app.listen(3000, () => console.log("API on :3000"));

Notes:

  • Run OPA as a sidecar: opa run —server —addr=:8181 -b ./policy-bundle
  • Distribute policies via OPA bundles and configure periodic pulls for reliability.
  • Verify JWT signatures in production and enforce issuer, audience, expiry, and nonce as needed.

Alternative: Embedded ABAC with Casbin (Go)

For services that prefer a library over a sidecar:

// go get github.com/casbin/casbin/v2
// model.conf (ABAC matcher example)
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub_rule, obj_rule, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act

// Example policy line (CSV):
// p, sub.tenant_id == obj.tenant_id && (sub.role == "admin" || sub.id == obj.owner_id), obj.type == "document", read

This approach suits simpler deployments but lacks central policy distribution out of the box.

Getting Attributes into the Decision

  • Subject attributes: prefer JWT claims with minimal PII; include tenant_id, role(s), assurance level. Consider token exchange to elevate auth strength.
  • Resource attributes: fetch from your DB or cache; avoid over-fetching. Consider embedding coarse attributes (e.g., tenant_id) in resource identifiers or indexes.
  • Environment attributes: obtain from request headers, device posture checks, geo-IP, or risk engines.

Use a PIP layer to standardize and cache attribute retrieval.

Performance and Reliability

  • Co-locate PDP with services (sidecar) to minimize latency.
  • Cache resource attributes and decisions with short TTLs; invalidate on updates.
  • Apply timeouts and safe fallbacks (default deny) on PDP outages.
  • Use partial evaluation or pre-compiled policies for data filtering use cases.
  • Keep policies small and modular; avoid overly complex rules that require large data joins.

Auditing and Explainability

  • Enable decision logs with input, result, and policy metadata; ship to your SIEM.
  • Include correlation IDs from requests for traceability.
  • Provide reason codes or a brief explanation in denial responses where safe to do so.

Data Filtering and Query Rewriting

ABAC often needs to filter lists, not just gate single-object access. Options:

  • Pre-authorize each item (expensive).
  • Use policy-driven query fragments: derive a where clause from policy (e.g., tenant_id match, ownership).
  • Use OPA partial evaluation to generate constraints your service applies to the database query.
  • For analytics, consider column-level redaction and row-level security.

Policy Lifecycle and CI/CD

  • Store policies in version control.
  • Add unit tests for policies (OPA: rego tests; use conftest in CI).
  • Review and sign policies; use canary releases for high-risk changes.
  • Maintain a policy catalog and documentation of attributes and invariants.
  • Monitor drift across environments via policy digests.

Migration Strategy from RBAC

  • Map roles to attributes initially (e.g., role implies a set of attributes).
  • Introduce tenant isolation and ownership checks first.
  • Gradually codify exceptions as attributes (assurance level, sensitivity).
  • Sunset legacy role checks after coverage overlaps.

Security Considerations

  • Treat attributes as untrusted unless they come from verified sources (signed tokens, trusted services).
  • Validate and normalize all inputs (types, ranges, enums).
  • Protect PDP endpoints; authenticate PEP to PDP.
  • Avoid leaking sensitive attributes in error messages or logs.
  • Handle clock skew for time-based rules.
  • Keep a tight schema for claims; reject tokens lacking mandatory claims.

Quick Checklist

  • Default deny is enforced.
  • Tenant isolation baked into policies.
  • Attributes are well-defined, validated, and minimal.
  • PDP is close to PEP, with timeouts and retries.
  • Policies are versioned, tested, and logged.
  • Decisions and attribute sources are auditable.
  • Data filtering strategy is in place for list endpoints.

With a clear attribute schema, a robust policy engine, and disciplined operations, ABAC can deliver fine-grained, explainable, and scalable authorization across your API surface.