Skip to content

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:

  • PrincipalUser::"<sub>" where <sub> is the identity token's sub claim. Memberships in Group::"<name>" come from the groups claim. Multiple Cedar groups, nested groups, and an Organization parent 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 carries environment, engine (databases), and arbitrary labels as Cedar tags — read in policies as resource.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_role on databases, login_user on mintOnwardSshCert, forward_target / forward_bind on the SSH forward actions), source (src_ip when known), workflow (ticket_open, ticket_id, recheck_confirmed, approved_request when 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

entity Organization;
entity Project in [Organization];
entity Group   in [Group];   // groups can nest

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

entity CertificateAuthority;
entity PolicyDoc;
entity AccessRequest;

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:

gdsgate --config auth.toml auth policy validate ./policy.cedar

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).