Dome Systems

Govern per end user

Give each user different tool access and answers from the same agent.

A shared agent has a shared problem. If the HR application can read every employee record, then everyone who talks to it can, whatever their own access happens to be. Copying your permission model into the agent's prompt is not a fix. The model can be talked out of it.

With standing identity, the agent acts as itself — every caller gets the same Cedar answer from the agent's grants. With delegated identity, each call carries a verified person, and Cedar authorizes on that person instead.

Hand this to an AI agent. It sets up act-as for Eva, Alice, and Carol on the HR application, then shows different answers from the same UI.

Open in Cursor

In this tutorial, you will switch from standing hr-assistant to a delegated agent, delegated-hr. Eva (HR), Alice (self), and Carol (peer) click the same buttons and get different answers. The app has no logic that knows the difference.

To do this, you will:

Register a delegated agent

Configure act-as verification on delegated-hr and make it mandatory. Leave hr-assistant standing.

Write rules about the person

Deploy Cedar that reads groups and compares act_as.sub to tool arguments.

Check the identities

Simulate Eva, Alice, and Carol before making a live call.

How this application implements it

Keep the token and act-as secret server-side. Sign per request.

Verify the results

See different answers from one UI, and each in audit.

Background

Earlier tutorials used hr-assistant standing. This one registers a second agent, delegated-hr, that requires act-as. Cedar reads principal.act_as, so Eva, Alice, and Carol can get different answers for the same tool.

hr-assistant (standing)delegated-hr (delegated)
Who authorizesThe agent's own grantsThe verified human on principal.act_as
CredentialBearer token onlyBearer token + required X-Dome-Act-As
Same tool, same recordIdentical outcome for everyoneEva allow · Alice self · Carol deny on get_employee E001
Anonymous callAllowed under standing grantsRejected (--actas-required)

You register a second agent rather than flipping hr-assistant to require act-as. Those settings would break earlier tutorials that call with no person attached. delegated-hr is the application agent that borrows each end user's identity.

Act-as carries that person on each call. The boundary is enforced at the gateway, not in the model's prompt.

That only works if your backend talks to Dome. Keep the token and signing secret on the server; the browser only says who is using the app. A frontend that called Dome directly could forge or omit act-as.

Prerequisites

For this tutorial, you will need:

Confirm the workspace before you start:

dome context current

The workspace should read sandbox-get-started. Switch back if it does not:

dome context use sandbox-get-started

Clone the reference app if you do not already have it from the previous tutorial:

git clone https://github.com/dome-systems/demo-hr-desk.git
cd demo-hr-desk
npm install
cp .env.example .env

If you already cloned it for Build a governed app, just cd into that directory. You will check out this tutorial's branch when you wire act-as.

This tutorial runs entirely in a sandbox. In a production workspace, the identity provider is wired once by an operator, and the rules that read its claims are owned by security.

Register a delegated agent

Register delegated-hr rather than reusing hr-assistant. Standing vs delegated is chosen per agent. hr-assistant stays the standing editor/curl agent from earlier tutorials. delegated-hr is the purpose-built application agent: it authenticates as itself, but every governed call must present a verified end user, and Cedar authorizes on that person.

This tutorial uses HMAC — a shared secret your backend uses to sign act-as. That is fine for a sandbox. In production, use OIDC instead: register with --actas-method oidc and forward a JWT from your identity provider so Dome verifies the person against the IdP, not a secret your service holds.

Generate the signing secret first so you can save it:

export ACTAS_SECRET=$(openssl rand -hex 32)
echo "$ACTAS_SECRET"
dome agents register --name delegated-hr \
  --actas-method hmac \
  --actas-hmac-secret "$ACTAS_SECRET" \
  --actas-required \
  --if-not-exists

Two flags carry the meaning:

  • --actas-method hmac tells the gateway how to verify the identity your service presents. Shared-secret HMAC is the sandbox path here; production uses oidc.
  • --actas-required rejects any call arriving without an identity. Without it, act-as is accepted when present and ignored when absent, and a caller who omits it gets the agent's own permissions.

Mint a credential for the application:

dome agents create-key delegated-hr --name delegated-hr-service

--actas-allowed-group, --actas-allowed-email, and --actas-allowed-subject add an admission-level allowlist, rejecting identities outside it before Cedar runs. That is a coarse gate for narrowing which population an agent may serve. Leave it unset here so Cedar makes every decision.

Write rules about the person

Cedar sees the end user as principal.act_as, a record carrying sub, email, roles, groups, and any custom claims the verified identity provided. Rules can read those the same way they read anything else.

This policy does four things:

  • Anyone with a verified identity can list employees and view the org chart
  • Someone in the hr group can open any employee record and read salary
  • Anyone else can open only their own record (act_as.sub matches employee_id)
  • Calls with no act-as identity, and tools outside that set, are denied

So Eva (HR) gets Alice's record and salary; Alice gets her own record only; Carol gets neither for Alice.

The reference repo ships delegated-hr.cedar. Create it (or copy from the clone):

delegated-hr.cedar
permit(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:discover",
  resource
);

permit(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource
) when {
  resource in [
    Dome::MCPTool::"demo-hr/hr/list_employees",
    Dome::MCPTool::"demo-hr/hr/org_chart"
  ]
};

// HR can open any employee; others only their own record (act_as.sub == employee_id).
permit(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource == Dome::MCPTool::"demo-hr/hr/get_employee"
) when {
  principal has act_as &&
  (
    principal.act_as.groups.contains("hr") ||
    (
      resource has arguments &&
      resource.arguments has employee_id &&
      resource.arguments.employee_id == principal.act_as.sub
    )
  )
};

// Payroll is HR-only.
permit(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource == Dome::MCPTool::"demo-hr/finance/get_salary"
) when {
  principal has act_as && principal.act_as.groups.contains("hr")
};

// Overrides the auto-generated agent_spec permit that opens every Default gateway tool.
forbid(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource == Dome::MCPTool::"demo-hr/hr/get_employee"
) unless {
  principal has act_as &&
  (
    principal.act_as.groups.contains("hr") ||
    (
      resource has arguments &&
      resource.arguments has employee_id &&
      resource.arguments.employee_id == principal.act_as.sub
    )
  )
};

forbid(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource == Dome::MCPTool::"demo-hr/finance/get_salary"
) unless {
  principal has act_as && principal.act_as.groups.contains("hr")
};

forbid(
  principal is Dome::Agent,
  action == Dome::Action::"mcp:call",
  resource
) unless {
  resource in [
    Dome::MCPTool::"demo-hr/hr/list_employees",
    Dome::MCPTool::"demo-hr/hr/org_chart",
    Dome::MCPTool::"demo-hr/hr/get_employee",
    Dome::MCPTool::"demo-hr/finance/get_salary"
  ]
};

The principal has act_as guard matters. act_as is absent from the entity entirely when no identity was verified, and reading an absent attribute is an evaluation error rather than a false. Guard it, and an anonymous call falls through to a clean deny.

Self-lookup compares resource.arguments.employee_id to principal.act_as.sub. Demo personas set sub to the employee id (E005 Eva, E001 Alice, E003 Carol), so Alice can open her own record while Carol cannot open Alice's. Payroll (get_salary) stays HR-only.

If you completed Call a model through a pool, Chat still needs llm:invoke on employee-summary. Tool rules do not cover inference. Create delegated-hr-llm.cedar (same shape as hr-assistant-llm.cedar, scoped to this agent):

delegated-hr-llm.cedar
permit(
  principal is Dome::Agent,
  action == Dome::Action::"llm:invoke",
  resource is Dome::LLMModel
) when {
  resource.pool == "employee-summary"
};

forbid(
  principal is Dome::Agent,
  action == Dome::Action::"llm:invoke",
  resource
) unless {
  resource has pool && resource.pool == "employee-summary"
};

Deploy both files scoped to the new agent, grant gateway access, and (for Chat) permit the pool:

dome rules apply delegated-hr.cedar delegated-hr-llm.cedar --agent delegated-hr --name delegated-hr
dome gateways access grant Default delegated-hr

Apply both Cedar files in one deploy so the tool rules and the pool rule stay active together. A second dome rules apply at the same agent scope replaces the previous bundle rather than layering beside it.

Check the identities

Simulation accepts an end-user identity, so you can test a rule that reads act_as without a signing secret, a service, or a live call. Pass --eval-arguments whenever the rule compares employee_id.

Eva (HR) looking up Alice:

dome rules simulate --agent delegated-hr --action mcp:call \
  --resource demo-hr/hr/get_employee --resource-type mcp_tool \
  --eval-arguments '{"employee_id":"E001"}' \
  --actas-sub E005 --actas-email eva@example.com --actas-groups hr

Expect ALLOW. Alice looking up herself (sub E001):

dome rules simulate --agent delegated-hr --action mcp:call \
  --resource demo-hr/hr/get_employee --resource-type mcp_tool \
  --eval-arguments '{"employee_id":"E001"}' \
  --actas-sub E001 --actas-email alice@example.com --actas-groups engineering

Expect ALLOW. Carol looking up Alice:

dome rules simulate --agent delegated-hr --action mcp:call \
  --resource demo-hr/hr/get_employee --resource-type mcp_tool \
  --eval-arguments '{"employee_id":"E001"}' \
  --actas-sub E003 --actas-email carol@example.com --actas-groups engineering

Expect DENY. Confirm salary is HR-only:

dome rules simulate --agent delegated-hr --action mcp:call \
  --resource demo-hr/finance/get_salary --resource-type mcp_tool \
  --eval-arguments '{"employee_id":"E001"}' \
  --actas-sub E005 --actas-email eva@example.com --actas-groups hr

dome rules simulate --agent delegated-hr --action mcp:call \
  --resource demo-hr/finance/get_salary --resource-type mcp_tool \
  --eval-arguments '{"employee_id":"E001"}' \
  --actas-sub E001 --actas-email alice@example.com --actas-groups engineering

Expect ALLOW then DENY. Confirm that the directory stays open to everyone by swapping the resource for demo-hr/hr/list_employees, and that omitting the act-as flags entirely denies the employee lookup rather than erroring.

This is the loop worth keeping. A rule that reads identity claims has more branches than a rule that reads a tool name, and simulation is where you find the branch you forgot.

How this application implements it

Act-as is a server-side concern. The gateway authenticates the agent with Authorization: Bearer dome_… and the end user with X-Dome-Act-As. Both headers are secrets your browser must never hold.

LayerHoldsTalks to Dome?
Frontend (src/App.tsx)Persona id only (eva / alice / carol)No. Only POST /api/chat and POST /api/tool on your origin
Backend (Hono proxy)DOME_TOKEN, DOME_ACTAS_SECRETYes. Signs act-as and calls /mcp and /v1/chat/completions

Same frontend/backend split as Build a governed app. With act-as the stakes are higher: if the browser held the agent token or signing secret, anyone with DevTools could impersonate anyone.

Check out this tutorial's branch, which adds the Act as picker and HMAC signing, then point the server at delegated-hr:

cd demo-hr-desk
git fetch origin
git checkout tutorial/govern-per-end-user

Update .env with the delegated-hr token and the secret you generated above. These values are read only by the Node process, never shipped to Vite or the browser:

.env
DOME_TOKEN=dome_...
DOME_GATEWAY_URL=https://GATEWAY_HOST/gateways/DEFAULT_GATEWAY_ID
DOME_POOL=employee-summary
DOME_ACTAS_SECRET=...
dome context current
dome gateways list

Do not prefix these with VITE_. Do not put DOME_TOKEN or DOME_ACTAS_SECRET in client bundles, public env, or browser storage. The frontend must not import them and must not fetch the Gateway URL.

How the application signs

The browser only sends a persona id (eva | alice | carol). The Hono proxy maps it to claims, signs with the server secret, and sets X-Dome-Act-As on the outbound Dome request. Cedar never sees a branch in App.tsx.

Personas are fixed claims for the demo. There is still no allow/deny logic in the UI:

server/actas.ts
export const PERSONAS = {
  eva: {
    id: "eva" as const,
    label: "Eva Martinez",
    detail: "HR Director",
    sub: "E005",
    email: "eva@example.com",
    groups: ["hr"],
  },
  alice: {
    id: "alice" as const,
    label: "Alice Johnson",
    detail: "Senior Engineer",
    sub: "E001",
    email: "alice@example.com",
    groups: ["engineering"],
  },
  carol: {
    id: "carol" as const,
    label: "Carol Williams",
    detail: "Staff Engineer",
    sub: "E003",
    email: "carol@example.com",
    groups: ["engineering"],
  },
} as const;

signHMACActAs builds the envelope the gateway verifies: identity fields + _ts, HMAC-SHA256 over canonical JSON | timestamp, then standard base64 of the signed payload. That string is the X-Dome-Act-As header value.

server/actas.ts
export function signHMACActAs(
  secret: string,
  identity: ActAsIdentity,
  ts = Math.floor(Date.now() / 1000),
): string {
  if (!identity.sub && !identity.email) {
    throw new Error("act-as identity requires sub or email");
  }

  const payload: Record<string, unknown> = { _ts: ts };
  if (identity.sub) payload.sub = identity.sub;
  if (identity.email) payload.email = identity.email;
  if (identity.groups?.length) payload.groups = identity.groups;
  // roles / claims omitted when empty

  const canonical = `${canonicalJson(payload)}|${ts}`;
  const digest = createHmac("sha256", secret).update(canonical).digest();
  payload._signature = digest.toString("base64url");

  return Buffer.from(canonicalJson(payload)).toString("base64");
}

export function actAsHeaderValue(identity: ActAsIdentity): string | undefined {
  const secret = process.env.DOME_ACTAS_SECRET?.trim();
  if (!secret) return undefined;
  return signHMACActAs(secret, identity);
}

Tool and chat routes attach the full signed header on the real gateway request. redactActAs is only for the View gateway call sheet. It truncates the header in the UI trace so the signed blob is not dumped into the browser transcript. Dome still receives the unredacted value.

server/dome.ts
if (actAs) {
  const signed = actAsHeaderValue(actAs);
  if (signed) {
    headers["X-Dome-Act-As"] = signed;                 // sent to Dome
    displayHeaders["X-Dome-Act-As"] = redactActAs(signed); // shown in the sheet
  }
}
server/types.ts
export function redactActAs(header: string) {
  if (header.length <= 16) return "<signed act-as>";
  return `${header.slice(0, 12)}…(${header.length} chars)`;
}

Start the app:

npm run dev

Open http://localhost:5173. The Act as bar appears when DOME_ACTAS_SECRET is set.

Click through Eva, Alice, and Carol

Stay on Tools for the clearest contrast:

  1. Select Eva Martinez (HR · hr · E005).
  2. Click List employees. Allowed.
  3. Click Who is E001?. Allowed. Email arrives as [REDACTED] (Filter still applies).
  4. Click What is Alice's salary?. Allowed (HR-only).
  5. Select Alice Johnson (Engineering · E001).
  6. Click Who is E001?. Allowed (self: sub matches employee_id). Email still redacted.
  7. Click What is Alice's salary?. Denied.
  8. Select Carol Williams (Engineering · E003).
  9. Click Who is E001?. Denied. Open View gateway call and confirm X-Dome-Act-As on the request. The response carries the Cedar reason.

If you want, try the same walk on Chat — ask Who is E001 and how do I reach them? as Eva, then Alice, then Carol. Same prompt, different outcomes. Model and tool steps both carry the signed identity.

PersonaList employeesWho is E001?Alice's salary
Eva (hr · E005)AllowedAllowed · redactedAllowed
Alice (E001)AllowedAllowed · self · redactedDenied
Carol (E003)AllowedDeniedDenied

One UI, one agent, one credential, three answers. Alice can still see herself. Carol cannot open Alice's record. Only Eva reaches payroll.

For a real identity provider, configure the agent with --actas-method oidc and forward the JWT your application already validated as X-Dome-Act-As. The gateway verifies the signature against the provider's discovery document, so your service never becomes the authority on who the caller is. HMAC is the sandbox path. Govern callers with OIDC builds that production path against your own identity provider.

Verify the results

Confirm both decisions are on the record:

dome audit query --limit 20

Each event carries the agent and the verified end user, so a denial is attributable to a person rather than to a shared service account. That is the audit property act-as buys you, and it is the one that matters during an investigation.

To read only the rejection:

dome audit query --results denied --limit 10

A rejected identity is a completed tool.call with result=denied, just like a policy refusal. Use denial.reason: act_as_rejected means the assertion did not verify, while policy_denied or permission_denied means authorization refused the call.

Quotas take end users as subjects too. dome quotas set --subject act-as --act-as E005 --limit 20 caps one person's inference spend, which is how you stop a single runaway session from consuming a team's budget.

Clean up

This is the last tutorial in the Get Started track. Delete the sandbox to remove the agents, rules, and grants together:

dome workspaces delete sandbox-get-started

To keep the workspace and retire only this agent:

dome agents revoke-key delegated-hr delegated-hr-service

Rotate the act-as secret anywhere you copied it. It signs identity assertions, so treat it like the credential it is. Clear DOME_ACTAS_SECRET (or switch back to the hr-assistant token) if you return to earlier tutorials.

Next steps

You learned how to require verified end-user identity, authorize from the person, and attribute decisions to both agent and user. Continue with:

On this page

Was this page helpful?