Policy¶
gdsgate authorises every request with a Cedar
policy. Cedar is a policy language with explicit permit / forbid rules,
strict schema validation, and a deterministic deny-by-default evaluation:
on any request, every matching rule is evaluated, and an explicit
forbid always wins over a permit.
This page describes the Cedar schema gdsgate exposes (entity types, actions, context fields), then walks through common policy patterns.
How a decision is reached¶
For every authorisation, gdsgate builds a Cedar (principal, action,
resource, context) request:
- Principal —
User::"<sub>"where<sub>is the identity token'ssubclaim. Memberships inGroup::"<name>"come from thegroupsclaim. Multiple Cedar groups, nested groups, and anOrganizationparent are all supported. - Action — derived from the operation:
- resource access uses the resource's kind (see Action map);
- listing uses
view; - SSH port-forward channels use
sshForwardLocal/sshForwardRemote; - downstream SSH certificate issuance uses
mintOnwardSshCert; - administrative actions use
rotateCA,editPolicy,createUser,disableUser,approveRequest.
- Resource — the requested resource id, as a Cedar entity of the
type the action requires (
Server,Database,KubernetesCluster,TcpService,McpServer,Tool). The entity carriesenvironment,engine(databases), and arbitrary labels as Cedar tags — read in policies asresource.environment,resource.engine,resource.getTag("team"), etc. - Context — request-time facts: identity-related (
mfa_satisfied,mfa_age_seconds,in_corp_vpn), temporal (timestamp,hour,weekday), session (db_roleon databases,login_useronmintOnwardSshCert,forward_target/forward_bindon the SSH forward actions), source (src_ipwhen known), workflow (ticket_open,ticket_id,recheck_confirmed,approved_requestwhen a JIT approval is active).
The cluster's policy file is strict-validated against the schema
at startup and may be validated standalone with
gdsgate auth policy validate. A policy that reads
resource.<attr> for an attribute the schema does not declare, or
applies an action to the wrong resource type, is rejected at load.
Action map¶
The authorisation action is derived from the operation, not chosen by the client. This is what lets one policy cover every protocol.
| Operation | Action | Resource entity |
|---|---|---|
gdsgate ls (RBAC-filtered listing) |
view |
Server / Database / KubernetesCluster / TcpService / McpServer |
| SSH session (model A or B) | sshConnect |
Server |
SSH -L channel |
sshForwardLocal |
Server |
SSH -R channel |
sshForwardRemote |
Server |
| SSH model-B downstream certificate | mintOnwardSshCert |
Server |
| PostgreSQL / MySQL connection | dbConnect |
Database |
| (Reserved: per-query gating) | dbQuery / dbWrite |
Database |
| Kubernetes API call | k8sAccess |
KubernetesCluster |
| (Reserved: read-only Kubernetes) | k8sReadOnly |
KubernetesCluster |
| TCP / Redis connection | tcpConnect |
TcpService |
MCP tools/call |
mcpCallTool |
Tool |
gdsgate request-access approval |
approveRequest |
AccessRequest |
| Administrative — rotate CA | rotateCA |
CertificateAuthority |
| Administrative — change policy | editPolicy |
PolicyDoc |
| Administrative — manage user accounts | createUser / disableUser |
User / Agent |
The view action always evaluates without session parameters
(db_role, approved_request) — it answers "can this user even see
this resource", so resources gated behind a session role or a pending
JIT approval still appear in listings.
Entity types¶
Below is the gdsgate Cedar schema, summarised. Brackets after a name
mean "may live under" (the Cedar in relationship). A ? next to an
attribute means it is optional in the schema.
Structural¶
Principals¶
entity User in [Group, Organization] = {
"email"?: String,
"org"?: Organization,
"mfa_enrolled"?: Bool
};
entity Agent in [Group, Project] = {
"spiffe_id"?: String,
"project"?: Project,
"kind"?: String
};
User attributes are optional — entity stores can be built incrementally and many policies match only on group membership.
Target resources¶
environment is required on every infra resource so policies can
read resource.environment == "prod" without has guards. Labels
declared on [[discovery.resources]] ride along as Cedar tags
(resource.getTag("team") / hasTag).
entity Server in [Project] = {
"environment": String
} tags String;
entity KubernetesCluster in [Project] = {
"environment": String
} tags String;
entity Database in [Project] = {
"environment": String,
"engine"?: String // "postgres" / "mysql"
} tags String;
entity McpServer in [Project] = {
"environment": String
};
entity Tool in [McpServer] = {
"name"?: String,
"mutating": Bool // policies can `forbid` mutating tools
};
entity TcpService in [Project] = {
"environment": String,
"port"?: Long
};
Control-plane resources¶
These exist so administrative actions (rotateCA, editPolicy,
approveRequest) target a real Cedar entity.
Context fields per action¶
Most actions share a common context block:
| Field | Type | Meaning |
|---|---|---|
mfa_satisfied |
Bool | Whether MFA was completed for this session. |
mfa_age_seconds |
Long | Seconds since MFA was performed. |
in_corp_vpn |
Bool | Whether the connection arrived through the corp VPN. |
timestamp |
Long | Unix-epoch seconds of the request. |
hour |
Long | Local hour (0–23). |
weekday |
Long | Local weekday (0 = Monday). |
ticket_open |
Bool | An open change ticket is required for many prod actions. |
ticket_id |
String | The ticket id. |
recheck_confirmed |
Bool | A recent recheck of the user's identity (re-prompt) was satisfied. |
src_ip |
ipaddr (optional) | Client source IP when known. Guard with context has src_ip. |
approved_request |
record (optional) | Present only when a JIT approval is active for this (principal, resource); has expires: Long. Guard with context has approved_request. |
Action-specific extensions:
| Action | Extra context |
|---|---|
dbConnect |
db_role: String — requested logical session-role profile name ("" if none). The policy decides who may assume which role. |
mintOnwardSshCert |
login_user: String — downstream POSIX user the certificate will name as its principal. |
sshForwardLocal |
forward_target: { host: String, port: Long } — the host:port the client asked the agent to dial. |
sshForwardRemote |
forward_bind: { host: String, port: Long } — the host:port the client asked the agent to bind a listener on. |
Policy patterns¶
The minimal building blocks for a real policy.
Allow a group through¶
The simplest pattern: scope every action to a group.
permit(principal, action == Action::"sshConnect", resource)
when { principal in Group::"engineers" };
permit(principal, action == Action::"dbConnect", resource)
when { principal in Group::"engineers" };
permit(principal, action == Action::"view", resource)
when { principal in Group::"engineers" };
Without a view permit, gdsgate ls returns empty even when the
underlying connect actions are allowed.
Restrict by environment¶
resource.environment comes from [[discovery.import_rules]] (or a
declared [[discovery.resources]] row). Use it to keep production
behind a stricter gate.
// Anyone may connect to non-prod
permit(principal, action == Action::"sshConnect", resource)
when {
principal in Group::"engineers"
&& resource.environment != "prod"
};
// Prod requires the prod-ops group AND an open ticket
permit(principal, action == Action::"sshConnect", resource)
when {
principal in Group::"prod-ops"
&& resource.environment == "prod"
&& context.ticket_open
};
Restrict by label / tag¶
Labels live as Cedar tags. Read them with getTag.
// Only the backend team may touch backend-tagged resources
permit(principal, action == Action::"dbConnect", resource)
when {
principal in Group::"backend"
&& resource.getTag("team") == "backend"
};
forbid rules combine cleanly:
// Critical resources are denied to anyone without an active JIT approval,
// regardless of any permit above.
forbid(principal, action == Action::"dbConnect", resource)
when {
resource.getTag("criticality") == "high"
&& !(context has approved_request)
};
Hide a single resource from listings¶
A forbid on view against one resource keeps every other access
path intact and just removes it from gdsgate ls:
forbid(principal, action == Action::"view",
resource == TcpService::"prod-redis")
when { !(principal in Group::"sre") };
Database session roles¶
context.db_role is the requested logical role; the agent will
bind the connection as the real DB user only if the policy says yes.
// readonly is broad; writer is admins-only.
permit(principal, action == Action::"dbConnect", resource)
when { context.db_role == "readonly" };
permit(principal, action == Action::"dbConnect", resource)
when {
context.db_role == "writer"
&& principal in Group::"db-admins"
};
The mapping from "readonly" to a real DB user lives on the agent
([[agent.backends.db_roles]]); Cedar decides whether the principal
may assume that logical name.
SSH -L and -R¶
Both forwards have an explicit Cedar action, both are scoped to a
Server, and both receive the requested host:port.
// Allow the admins group to forward anywhere the agent permits at Layer 1.
permit(principal, action == Action::"sshForwardLocal", resource)
when { principal in Group::"admins" };
// `-R` only on loopback bind — even before any Layer-1 entry.
permit(principal, action == Action::"sshForwardRemote", resource)
when {
principal in Group::"admins"
&& context.forward_bind.host == "127.0.0.1"
};
Model-B downstream certificate minting¶
When the agent reaches a downstream sshd (model B), it asks Auth for
an OpenSSH user certificate, and mintOnwardSshCert is the gate. The
login_user is the downstream POSIX account the certificate will be
issued for.
// Operators may step into `deploy` but never `root`.
permit(principal, action == Action::"mintOnwardSshCert", resource)
when {
principal in Group::"operators"
&& context.login_user == "deploy"
};
forbid(principal, action == Action::"mintOnwardSshCert", resource)
when { context.login_user == "root" };
Approving access requests¶
approveRequest decides who may sign off on a pending JIT request.
The principal is the reviewer (User); the resource is the opaque
control-plane entity AccessRequest::"<id>", which carries no
attributes of its own in v1 — so policies match on the reviewer's
groups and the request-time context, not on the request itself.
The simplest pattern — one approver group:
permit(principal, action == Action::"approveRequest", resource)
when { principal in Group::"approvers" };
Tightenings: require a fresh MFA and an open change ticket before any approval is accepted.
permit(principal, action == Action::"approveRequest", resource)
when {
principal in Group::"approvers"
&& context.mfa_satisfied
&& context.mfa_age_seconds < 600
&& context.ticket_open
};
How many distinct approvers must sign off is a separate decision,
configured in [approvals]: a cascade
that narrows from per-resource through per-environment to the global
default. The store de-duplicates by reviewer username, so a reviewer
running gdsgate approve twice still counts as one. Once the
threshold is reached, the request is sealed — further approve calls
become no-ops.
v1 limitations
The Cedar context for approveRequest does not yet carry the
request's requester or the target resource. A policy cannot forbid
self-approval through Cedar alone, nor key approver groups by which
environment or project the requested resource belongs to. Enforce
the separation operationally — keep requesters and approvers in
different Cedar groups — and use the [approvals] cascade to
differentiate thresholds by environment.
JIT approval as the only way in¶
A forbid on every direct connect, plus a permit gated on an active
approval, forces the request-flow.
forbid(principal, action == Action::"dbConnect", resource)
when {
resource.environment == "prod"
&& !(context has approved_request)
};
permit(principal, action == Action::"dbConnect", resource)
when {
resource.environment == "prod"
&& context has approved_request
&& context.approved_request.expires > context.timestamp
};
The approval threshold itself is configured by the
[approvals] cascade.
Administrative actions¶
Even certificate-authority rotation goes through Cedar.
permit(principal, action == Action::"rotateCA", resource)
when { principal in Group::"sec" };
permit(principal, action == Action::"editPolicy", resource)
when {
principal in Group::"sec"
&& context.recheck_confirmed
};
A worked group-scoped policy¶
A small but realistic policy file you can paste, then tighten:
// Listing: every member of `engineers` sees every resource.
permit(principal, action == Action::"view", resource)
when { principal in Group::"engineers" };
// SSH: engineers on non-prod; prod requires `prod-ops` and an open ticket.
permit(principal, action == Action::"sshConnect", resource)
when {
principal in Group::"engineers"
&& resource.environment != "prod"
};
permit(principal, action == Action::"sshConnect", resource)
when {
principal in Group::"prod-ops"
&& resource.environment == "prod"
&& context.ticket_open
};
// Databases: engineers may use the `readonly` profile everywhere;
// `writer` is reserved for `db-admins`.
permit(principal, action == Action::"dbConnect", resource)
when {
principal in Group::"engineers"
&& context.db_role == "readonly"
};
permit(principal, action == Action::"dbConnect", resource)
when {
principal in Group::"db-admins"
&& context.db_role == "writer"
};
// Kubernetes: only `kube-ops`.
permit(principal, action == Action::"k8sAccess", resource)
when { principal in Group::"kube-ops" };
// Raw TCP: engineers, but the prod Redis cache is hidden from listings
// for non-SREs.
permit(principal, action == Action::"tcpConnect", resource)
when { principal in Group::"engineers" };
forbid(principal, action == Action::"view",
resource == TcpService::"prod-redis")
when { !(principal in Group::"sre") };
// Forwards: enabled for admins, `-R` restricted to loopback binds.
permit(principal, action == Action::"sshForwardLocal", resource)
when { principal in Group::"admins" };
permit(principal, action == Action::"sshForwardRemote", resource)
when {
principal in Group::"admins"
&& context.forward_bind.host == "127.0.0.1"
};
Validating a policy¶
Before deploying a policy, validate it against the schema:
Non-zero exit on any error — use as a CI gate so a broken policy never reaches Auth.
Auth re-validates at startup too: a malformed or schema-incompatible policy aborts startup. A loaded policy is not hot-reloaded in v1; edit, validate, and restart Auth (or rolling-restart a multi-instance Auth set).