Layered Authorization: Network to Database
How network, request, resource, and field checks compose, with Postgres row-level security as the backstop

This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.
The previous post covered how Auth Service signs the mesh identity token that carries a user through the mesh. Once a request arrives with a verified identity, the next question is what that identity is allowed to do.
Authentication tells you who someone is. Authorization tells you what they're allowed to do. In this architecture, authorization runs at four granularities: network, request, resource, and field. Each operates at a different enforcement point with different information available, and each catches failures the others can't. A separate layer, PostgreSQL row-level security, sits at the database and backstops all of them when application code has a bug.
Why Four Granularities
A single policy engine can't efficiently handle every authorization decision. Some decisions require only the service identity (which pod is calling). Others require the user's role. Others require knowing the specific resource being accessed and the relationship between the user and that resource. And field visibility only makes sense at query time, when you know the actual data attributes.
Different information is available at different enforcement points:
At the sidecar: mTLS identity and JWT claims. No application context. This is where network-level authorization runs (Istio AuthorizationPolicy).
At the gateway: user role and route. No specific resource. This is where request-level authorization runs (ExtAuthz).
At the application: full request context, resource attributes, user-resource relationships. This is where Cerbos makes resource-level and field-level decisions.
At the database: transaction-level identity. No application logic. This is where row-level security runs. Strictly it's a data-access control rather than authorization, and it's the final enforcement point that backstops the others.
Trying to push all decisions to one point either means that point needs all context (which defeats the separation) or that some decisions can't be made at all.
Network (Sidecar): Istio AuthorizationPolicy
The first question: can this service even talk to that service?
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: allow-ms1-to-ms2
namespace: zt-apps
spec:
selector:
matchLabels:
app: ms2-employee-details
action: ALLOW
rules:
- from:
- source:
principals:
- cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa
to:
- operation:
ports: ["8000"]
when:
- key: request.auth.claims[aud]
values: ["ms2-employee-details"]
- key: request.auth.claims[act][sub]
values: ["ms1-profile-aggregator"]
This policy says: only Profile Aggregator's service account (ms1-profile-aggregator-sa) can reach Employee Records, and only with a token that has the correct audience and delegation claims. Everything else is denied.
Important nuance: Istio's default behavior when no AuthorizationPolicy targets a workload is to allow all traffic. Default-deny only kicks in once at least one ALLOW policy exists for that workload. This means every service that should be protected needs an explicit policy. A newly deployed service without a matching policy is open until you write one. In this architecture, every workload in zt-apps has at least one scoped ALLOW policy, which triggers deny-by-default for unmatched requests.
What this catches: lateral movement. If Holiday Calendar is compromised, the attacker cannot reach Employee Records. The connection is refused at the sidecar level before any application code processes the request. The attacker would need both the correct service account identity (the mTLS certificate) and a validly-signed token with the right claims.
The full boundary map:
| Source | Can reach | Cannot reach |
|---|---|---|
| Ingress Gateway | Profile Aggregator, Holiday Calendar, Office Directory, Auth Service | Employee Records, Device Inventory, Vault, Postgres |
| Profile Aggregator | Employee Records, Device Inventory | Cerbos, Vault, Keycloak, Postgres |
| Employee Records, Device Inventory | Cerbos, Postgres (own schema only) | Profile Aggregator, Holiday Calendar, Office Directory, Vault |
| Holiday Calendar, Office Directory | Cerbos, Postgres (own schema only) | Profile Aggregator, Employee Records, Device Inventory, Vault |
| Auth Service | Vault, Postgres | Employee Records, Device Inventory, Holiday Calendar, Office Directory |
Every cell in this table is an explicit decision. Unmatched traffic is denied because each workload has at least one ALLOW policy targeting it.
Request (Gateway): ExtAuthz
The second question: is this user, with this role, allowed to call this route?
Auth Service (auth-service) acts as an external authorization check at the gateway. Before minting a mesh token, it checks a route-role policy map:
| Route | Method | Allowed Roles |
|---|---|---|
/api/profile/* |
any | employee, manager, hr_admin, it_admin |
/api/holidays |
GET | employee, manager, hr_admin, it_admin, public_data_admin, security_auditor |
/api/holidays |
POST/PUT/DELETE | hr_admin, public_data_admin |
/api/offices/* |
any | (bypasses ExtAuthz entirely; see below) |
The system has six roles total (employee, manager, hr_admin, it_admin, public_data_admin, security_auditor). The core access model for the profile/HR flow is built around the first four. public_data_admin manages office and holiday data. security_auditor has read access to non-sensitive endpoints.
Office routes are the exception: the gateway's AuthorizationPolicy exempts /api/offices and /api/offices/* from ExtAuthz entirely. Reads are public, and write authorization (public_data_admin only) is enforced downstream by Cerbos at Office Directory, not at the gateway. Everything else routes through the map above.
A public_data_admin trying to access the profile endpoint gets a 403 at the gateway. They never hit Profile Aggregator. The token is never minted. Downstream services never see the request.
This is coarse authorization. It knows the user's role and the route they're hitting, but it knows nothing about which specific resource they want to access. A manager can access the profile endpoint, but that doesn't mean they should see every employee's salary; they should only see pay data for their own direct reports. That's a different layer's job.
The key decision here is what gets denied early (saving downstream resources and reducing attack surface) versus what gets deferred to finer-grained layers that have more context.
Resource and Field (Application): Cerbos
The third and fourth granularities: can this user access this specific resource? And which fields should they see?
Cerbos evaluates context-aware policies. It receives the principal (user ID, roles, attributes), the resource (type, ID, owner, attributes), and the action. It returns an allow/deny decision and a list of visible fields.
# cerbos/policies/employee_profile.yaml (abbreviated: view/list and hr_admin rules omitted)
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: employee_profile
rules:
- actions: ["view_sensitive", "update"]
effect: EFFECT_ALLOW
roles: ["employee"]
condition:
match:
expr: request.principal.id == request.resource.attr.id
output:
expr: |-
{"visible_fields": ["name", "title", "department", "salary_band", "base_salary", "ssn"]}
- actions: ["view_sensitive"]
effect: EFFECT_ALLOW
roles: ["manager"]
condition:
match:
expr: request.principal.id == request.resource.attr.manager_id
output:
expr: |-
{"visible_fields": ["name", "title", "department", "salary_band"]}
What this means concretely: Alice (employee) views her own profile and sees everything including salary and SSN. Bob (manager) views Alice's profile and sees the salary band but not the exact salary or SSN. Carol (another employee) views Alice's profile and sees only name, title, and department.
Same endpoint. Same authentication. Same route-level authorization. Different data, determined by the relationship between the user and the resource.
What Cerbos catches that the previous layers cannot: role alone isn't sufficient. A manager should see sensitive data for their direct reports, not for every employee in the company. This requires knowing who the resource belongs to and what the manager's relationship is to that person. The gateway doesn't have that information. Only the application, at query time, knows these relationships.
Field Masking
Cerbos doesn't filter data itself. It returns a list of visible fields, and the application code uses that list to shape the response. This is a contract between policy and application:
result = cerbos.check_resource(
principal={"id": user_id, "roles": [role]},
resource={"kind": "employee_profile", "id": employee_id, "attr": {"manager_id": ...}},
actions=["view_sensitive"]
)
visible = result.outputs["visible_fields"]
# Application filters the response to only include these fields
The application must respect this output. If it doesn't, row-level security on the sensitive tables (PII and financials) still limits which rows the database returns, though the directory table is open and relies on Cerbos for field masking. Field-level masking specifically depends on correct application implementation. This is one place where the "security as infrastructure" model has a boundary. The mesh can't mask JSON response fields for you.
Database (Backstop): PostgreSQL Row-Level Security
The final question: even if everything above passes, should the database return this row?
Row-level security here is deliberately split. The employee directory table (hr.employees, holding name, title, department, manager) is readable by any authenticated principal, because the directory itself isn't sensitive and Cerbos handles field masking on top of it. The sensitive tables (hr.employee_pii, hr.employee_financials) carry the strict row-level policy:
CREATE POLICY employee_financials_visibility ON hr.employee_financials
USING (
current_setting('app.current_roles', true) LIKE '%hr_admin%'
OR employee_id::text = current_setting('app.current_user_id', true)
OR EXISTS (
SELECT 1 FROM hr.employees e
WHERE e.id = employee_id
AND e.manager_id::text = current_setting('app.current_user_id', true)
)
);
ALTER TABLE hr.employee_financials ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.employee_financials FORCE ROW LEVEL SECURITY;
This policy says: you can see a financial record if you're an hr_admin, if it's your own, or if you manage that employee. Nothing else. A query with no WHERE clause still returns only the rows this user is allowed to see. The PII table (hr.employee_pii) uses the same shape, and it.hardware_assets uses self-or-it_admin.
RLS uses current_setting('app.current_user_id') and current_setting('app.current_roles'), which are set by the application at the start of each transaction from the sidecar-projected headers. The database has no knowledge of JWTs, sidecars, or mesh tokens. It just enforces visibility based on the transaction context it's given.
Why Cerbos and RLS
The two overlap, but they catch different failures:
Cerbos fails (policy bug, service misconfiguration, Cerbos unreachable): The services fail closed. If Cerbos is unreachable, the application returns 403 (the client catches all Cerbos errors and denies by default). If a policy bug returns an incorrect allow decision, RLS on the sensitive tables still drops the PII and financial rows the user can't see. The response might leak directory fields that should have been masked, but the sensitive records stay protected.
Application fails (developer forgets to call Cerbos, broken filter logic, new endpoint without an auth check): RLS still enforces on the sensitive tables. SELECT * FROM hr.employee_financials without any Cerbos check still returns only rows matching the RLS policy. The mistake doesn't become a financial-data breach.
RLS fails (misconfigured policy, wrong user ID set on transaction): The query order is: the service loads the employee record first (to obtain attributes like manager_id for the Cerbos policy), then calls Cerbos, then queries the sensitive tables only after Cerbos approves. That initial directory read is intentionally open, so Cerbos is what gates the sensitive query. A broken sensitive-table RLS policy widens which rows come back, but Cerbos already gated access to those tables. A broken Cerbos policy widens what's returned, but RLS still limits sensitive-row visibility.
Neither Cerbos nor RLS alone is sufficient. Together, an application developer's mistake stops short of full exposure of PII or financial data.
Schema Isolation
RLS is complemented by PostgreSQL GRANT-level isolation. Each service has a dedicated database role:
ms2_hr_rolecan only accesshr.*tables (employees, PII, financials)ms3_it_rolecan only accessit.hardware_assetsms4_public_readwrite_rolecan only accesspublic_data.*
Even without RLS, Device Inventory physically cannot query hr.employee_financials. The GRANT isn't there. This is a separate enforcement layer from RLS. If Device Inventory is compromised and the attacker somehow bypasses Istio AuthorizationPolicy (they'd need the mTLS cert and a valid token), the database still won't serve them HR data because the connection role lacks the privilege.
How the Granularities Compose: A Concrete Example
Alice (employee) requests her own profile with GET /api/profile/{alice_id}:
Network: Gateway can reach Profile Aggregator. Profile Aggregator can reach Employee Records. ✓
Request: Alice has the
employeerole,/api/profile/*allows employees. Token minted. ✓Resource and field (Cerbos): Alice is requesting her own record (
principal.id == resource.attr.id). Allowed, with visible fields that include salary and SSN.Database (RLS): When the app reads her financials and PII,
app.current_user_idmatches theemployee_idon those rows, so they're returned.
Bob (employee) tries to see Alice's financial data. His request also comes in as GET /api/profile/{alice_id}, and Profile Aggregator fans out toward Alice's sensitive records:
Network: Same path. ✓
Request: Bob has the
employeerole,/api/profile/*allows employees. Token minted. ✓Resource and field (Cerbos): When the app tries to read Alice's financials, it checks the
view_sensitiveaction. Bob is not Alice and not Alice's manager. Denied. The/financialsand/piiendpoints on Employee Records are all-or-nothing, so the response is a 403 with no partial data.Database (RLS), if Cerbos had a bug and allowed it: The RLS policy on
hr.employee_financialsrequiresemployee_id == current_user_idor that the caller manages the employee. Bob is neither. Zero rows returned. The bug at the resource layer is caught at the database.
Bob viewing Alice's basic profile is a different action on the directory: the view action is allowed for any employee, and Cerbos returns visible_fields: [name, title, department]. Same request path, separate actions, separate policies, and the directory row is readable while the sensitive tables are not.
What's Next
The final post covers the patterns and anti-patterns across all layers: what makes this architecture compose well, what operational pain points exist, and what you'd do differently at scale.



