Permission Models That Survive the First External Integration
For developers designing authorization in systems that will soon talk to partners, internal tools, and background jobs. This guide shows how to structure permissions around capabilities, resources, and policy evaluation so your first integration does not force a rewrite.
TL;DR — Most permission models fail at the first integration because they encode UI roles instead of API capabilities and resource boundaries. The safest default is: keep identities, roles, and permissions separate; evaluate permissions against a resource plus context; and issue integration-specific service principals with narrowly scoped capabilities instead of reusing human roles. Reading time: ~7 min
What it is and where it sits
A permission model that survives the first integration is an authorization design that still works when a second system starts calling your API, syncing data, or acting on behalf of users. The failure mode is predictable: v1 ships with app-centric roles like admin, manager, viewer; then an ERP, webhook processor, support tool, or data export job arrives and none of those roles cleanly map to what that integration needs.
What you want instead is a model with these separate pieces:
- Principal: user, service account, batch job, webhook consumer.
- Action:
invoice.read,invoice.approve,customer.export. - Resource: a specific invoice, customer, tenant, project.
- Context: tenant, ownership, environment, network, time, approval state.
- Policy: the rule that decides allow/deny.
Architecturally, this sits in the request path after authentication and before business logic mutates data. It replaces scattered if user.is_admin checks in handlers, controllers, SQL queries, and frontend conditionals.
Typical flow:
[Client/User/Integration]
|
v
[AuthN: session/JWT/mTLS/API key]
|
v
[AuthZ policy evaluation layer]
|
allow? | deny?
yes v no --> 403 / 404 / audit log
[Business service]
|
v
[DB / queue / downstream API]
What talks to it:
- API gateway or app middleware passes authenticated identity and request attributes.
- Business services ask it questions like “can principal X do action Y on resource Z?”
- Data layer may receive authorization-derived filters, for example
tenant_id IN (...). - Audit logging records the decision, not just the final HTTP status.
What it replaces:
- Hardcoded role checks in controllers.
- Shared “admin API key” used by every integration.
- Permission decisions implied by route naming or frontend visibility.
- SQL with authorization mixed into every query in slightly different ways.
How it actually works
The durable pattern is: authenticate first, then authorize against a normalized tuple:
{
"principal": "svc:erp-sync",
"action": "invoice.read",
"resource": {
"type": "invoice",
"id": "inv_123",
"tenant": "acme"
},
"context": {
"on_behalf_of": null,
"environment": "prod",
"ip": "203.0.113.10"
}
}
Then evaluate policy in this order:
- Resolve the principal type and bindings.
- Expand assigned roles into capabilities, if you use roles at all.
- Apply resource scoping, usually tenant/org/project first.
- Apply contextual constraints.
- Return allow/deny plus the reason.
End-to-end example: ERP sync reads invoices but cannot approve them
Assume your app already has human users with roles:
billing-adminbilling-analyst
Now an ERP integration needs to sync all posted invoices for tenant acme. If you reuse billing-admin, you have already lost: the ERP can now approve invoices, edit payment terms, maybe export customer PII, and your audit trail says “admin did it.”
Instead, create a separate service principal:
- principal:
svc:erp-sync - scopes/capabilities:
invoice.read,customer.read-basic - resource boundary: tenant
acme - context restriction: machine-to-machine only, no interactive login
Step by step request flow:
- The ERP calls
GET /v1/invoices?status=postedwith a client credential or signed token. - Authentication resolves the caller to
svc:erp-sync. - Middleware constructs an authorization input from the route, resource type, tenant, and token claims.
- Policy engine checks whether
svc:erp-synchasinvoice.readfor tenantacme. - Query layer adds
WHERE tenant_id = 'acme' AND status = 'posted'. - Response is returned and the decision is logged.
Now the ERP tries POST /v1/invoices/inv_123/approve.
Authentication still succeeds. Authorization fails because the principal lacks invoice.approve. That distinction matters operationally.
A good API response shape is:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":"insufficient_permission","action":"invoice.approve","resource":"invoice:inv_123","principal":"svc:erp-sync"}
And your logs should distinguish authn from authz failure:
level=info msg="authz deny" principal="svc:erp-sync" action="invoice.approve" resource="invoice:inv_123" tenant="acme" reason="capability_missing" request_id="6f3c..."
That is what “survives the first integration” means in practice: adding a new caller becomes a policy/config change, not a schema rewrite or a new branch of role exceptions.
The design rule most teams skip
Do not assign permissions directly to UI roles and call it done. Define a stable permission namespace first, for example:
invoice.readinvoice.writeinvoice.approveinvoice.exportcustomer.read-basiccustomer.read-sensitive
Then map roles and service principals onto those permissions.
Why this survives integrations:
- Human roles can change with org charts.
- Integrations need machine-safe capabilities, not job titles.
- New channels like CLI, support tooling, and async workers can reuse the same permission vocabulary.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| Single-tenant internal tool with 2-3 trusted operators | Keep it simple: coarse roles plus tenant boundary may be enough |
| Multi-tenant SaaS with external API or planned partner integration | Use capability-based permissions with explicit resource scoping now |
| Background jobs and webhooks act in the system | Create separate service principals; do not reuse user roles |
| You need row-level restrictions by tenant/project/owner | Add resource-aware policy evaluation or DB row-level security |
| You only need feature flags, not security boundaries | Do not turn feature flags into authorization |
| You have one monolith and no external callers yet, but roadmap includes ERP/CRM/BI sync | Design the permission namespace now, even if enforcement stays simple initially |
You probably do not need a full policy engine if:
- all access is within one tenant,
- there are no machine-to-machine callers,
- the blast radius of over-permissioning is low,
- and you can enumerate all actions in a handful of route checks.
You probably do need one if any of these are true:
- customers demand scoped API credentials,
- support staff need temporary elevated access,
- integrations act on behalf of users,
- one resource can be visible but not mutable,
- or your audit/compliance requirements care about who approved what.
Trade-offs
Every benefit costs something.
-
Benefit: cleaner integrations
Cost: more modeling upfront. You have to name actions and resource types before they are urgently needed. -
Benefit: least privilege for service accounts
Cost: operational overhead. Someone must provision, rotate, and review those principals and scopes. -
Benefit: consistent authorization across API, jobs, and admin tooling
Cost: central dependency. If policy evaluation is down or slow, requests fail or stall unless you build caching/fallbacks carefully. -
Benefit: better auditability
Cost: log volume and sensitivity. Authorization logs can expose resource identifiers and user relationships; treat them as sensitive data. -
Benefit: easier future migration to policy-as-code or external IAM
Cost: abstraction tax. Poorly designed wrappers can hide useful details and make debugging harder than direct checks. -
Benefit: safer multi-tenancy
Cost: latency and query complexity. Resource-scoped checks often require loading tenant/project ownership data or pushing filters into SQL.
The common bad trade is pretending simplicity is free. Hardcoded role checks feel cheap until the first partner asks for read-only access to one subset of data and your only answer is “give them admin.”
In practice
Example 1: Postgres schema for principals, roles, and permissions
⚠️ Adding authorization tables is usually safe, but backfilling role bindings or changing foreign keys in production can lock tables and cause write latency. Run DDL in a migration window and inspect lock behavior on your Postgres version first.
create table principals (
id uuid primary key,
kind text not null check (kind in ('user', 'service')),
external_id text unique not null,
tenant_id uuid,
disabled boolean not null default false
);
create table permissions (
name text primary key
);
create table roles (
name text primary key
);
create table role_permissions (
role_name text references roles(name) on delete cascade,
permission_name text references permissions(name) on delete cascade,
primary key (role_name, permission_name)
);
create table principal_roles (
principal_id uuid references principals(id) on delete cascade,
role_name text references roles(name) on delete cascade,
tenant_id uuid,
primary key (principal_id, role_name, tenant_id)
);
This gives you a stable permission namespace and lets you bind roles per tenant. The gotcha: if you stop here, you still do not have resource-level authorization; tenant_id scoping is necessary but not sufficient for owner/project/document rules.
Example 2: Express middleware that distinguishes 401 from 403
app.use(async (req, res, next) => {
const auth = req.header('authorization');
if (!auth) {
return res.status(401).json({ error: 'missing_authorization' });
}
const principal = await authenticateBearerToken(auth);
if (!principal) {
return res.status(401).json({ error: 'invalid_token' });
}
req.principal = principal;
next();
});
function requirePermission(action, resourceResolver) {
return async (req, res, next) => {
const resource = await resourceResolver(req);
const decision = await authorize({
principal: req.principal,
action,
resource,
context: {
ip: req.ip,
method: req.method,
path: req.path
}
});
if (!decision.allow) {
req.log.info({
principal: req.principal.externalId,
action,
resource,
reason: decision.reason
}, 'authz deny');
return res.status(403).json({
error: 'insufficient_permission',
action,
reason: decision.reason
});
}
next();
};
}
app.post('/v1/invoices/:id/approve',
requirePermission('invoice.approve', async (req) => ({
type: 'invoice',
id: req.params.id,
tenant: req.principal.tenantId
})),
approveInvoiceHandler
);
This pattern keeps authentication and authorization separate and gives you debuggable denial reasons. The gotcha: deriving tenant from the principal alone is wrong for cross-tenant admin tools or delegated access; in those cases resolve the resource first and compare both sides.
Example 3: Diagnostic curl output for a denied integration call
curl -i https://api.example.com/v1/invoices/inv_123/approve \
-H 'Authorization: Bearer eyJhbGciOi...' \
-H 'Content-Type: application/json' \
-X POST
HTTP/2 403
content-type: application/json
x-request-id: 6f3c2a0d9b1e
{"error":"insufficient_permission","action":"invoice.approve","reason":"capability_missing"}
If the same call returns 401, debug token validation. If it returns 403, debug bindings and policy. That split saves hours during partner onboarding.
Further reading
- NIST RBAC Model
- OAuth 2.0 Bearer Token Usage
- Open Policy Agent Documentation, the "Policy Language" and "REST API" sections
- PostgreSQL Documentation, the "Row Security Policies" chapter
- Google Zanzibar paper
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI