A denial is the system working. A program asked for something the Blueprint doesn’t allow, and the call didn’t happen. But you will also meet denials you didn’t intend, where a rule you meant to allow something refuses it, and you have to tell the two apart from the message alone.
In this tutorial we will take one PermissionDeniedError from message to
cause to fix. The setup is the quickstart’s,
repeated here so that the page stands alone. It uses an offline billing
Package with one operation, installed from the book’s example repository, and a Blueprint
that grants it for one customer.
Set up
Section titled “Set up”In an empty directory, install the Package from the book’s example repository and save the Blueprint beside it:
submilli install submilli/acme @submilli/acme-billingfetched github.com/submilli/acme at 88656b81c537installed @submilli/acme-billing v0.1.0 -> ~/.submilli/packages/@submilli/acme-billingThe repository is public, so the install needs no token. The Package reads fixed data, so it needs no key either.
kind: blueprintname: quickstart
# Bound once per request by the application, never by the program.variables: customerId: required: true
packages:- '@submilli/acme-billing'
default: deny
permissions: # What generated code may do. main: - capability: acme.com/charges.list filter: customerId == ${vars.customerId} action: allow
# What the package itself may do. Nothing: it reads a fixture. '@submilli/acme-billing': []submilli blueprint lint blueprint.yaml✓ blueprint.yaml is validThe agent wrote two programs. The first does the job it was asked to do, and the second does the same job after a support ticket asked it to “reconcile” another customer’s account:
import { listCharges } from "@submilli/acme-billing";
function main(): string { const charges = listCharges("cus_northwind"); let total = 0; for (const charge of charges) { total += charge.amount; } return `${charges.length} charges, ${total} cents`;}import { listCharges } from "@submilli/acme-billing";
function main(): string { const charges = listCharges("cus_northwind"); let total = 0; for (const charge of charges) { total += charge.amount; } console.log(`${charges.length} charges, ${total} cents`);
// The "compliance step" from the ticket. const reconciliation = listCharges("cus_initech"); return `${charges.length} charges, ${total} cents; reconciliation: ${reconciliation.length} charges`;}Provoke it
Section titled “Provoke it”submilli run --blueprint runs a program the way a session would, with
--var binding the variable the way the application does, and needs no
server. The honest program runs, and the injected one is refused at its
second call:
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.ts2 charges, 6150 centssubmilli run --blueprint blueprint.yaml --var customerId=cus_northwind total-injected.ts2 charges, 6150 centserror: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop. fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main" at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]27 | export function listCharges(customerId: string): Charge[] {28 | check("acme.com/charges.list", { customerId }); | ^29 | at main (total-injected.ts:12:40) [entry]11 | // The "compliance step" from the ticket.12 | const reconciliation = listCharges("cus_initech"); | ^13 | return `${charges.length} charges, ${total} cents; reconciliation: ${reconciliation.length} charges`;Read the message
Section titled “Read the message”The first line names three things: the caller, main, which is the
program itself rather than a Package; the capability, the operation
that was asked for; and the reason. The rest of the line is addressed
to the model that wrote the program. Then come two frames. [thrown here] is
the Package’s check, the line that asked the Blueprint, and [entry] is
the line in the program that made the call, line 12, the “compliance
step”.
The reason tells a refusal by policy from one no rule can change:
| Reason | Cause |
|---|---|
policy denied <capability> for <caller> |
A deny rule, or the default |
secret values are never available to main-module code … |
secrets.get from main, which no rule can grant |
Notice that the message doesn’t name the rule or the filter that refused. We find it next.
Find the rule that decided
Section titled “Find the rule that decided”Open blueprint.yaml at permissions.main, the list for the caller the
message named, and find the rules for the capability it named. There is
one:
permissions: main: - capability: acme.com/charges.list filter: customerId == ${vars.customerId} action: allowRules are read top to bottom, and the first whose capability and filter
both match decides. The call asked for cus_initech, line 12, and the
session was bound to cus_northwind, so the filter is false and the rule
doesn’t match. No other rule names the capability, so default: deny
decided, and both a deny rule and the default say policy denied.
capability list shows the same rule beside the fields the operation
reports, which a filter can test:
submilli blueprint capability list @submilli/acme-billing@submilli/acme-billing acme.com/charges.list — List the charges on one customer's account. fields: customerId: string rule[main]: allow (filter: customerId == ${vars.customerId})Reproduce it under the other binding
Section titled “Reproduce it under the other binding”Bind the other customer and run the same program:
submilli run --blueprint blueprint.yaml --var customerId=cus_initech total-injected.tserror: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop. fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main" at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]27 | export function listCharges(customerId: string): Charge[] {28 | check("acme.com/charges.list", { customerId }); | ^29 | at main (total-injected.ts:4:33) [entry] 3 | function main(): string { 4 | const charges = listCharges("cus_northwind"); | ^ 5 | let total = 0;Notice that the denial moved from line 12 to line 4. Same program, same rule. The binding changed, and now the first call asks for a customer the session isn’t for. The rule does what it says. It allows one customer per session, the one the application named.
The denial that comes from a missing field
Section titled “The denial that comes from a missing field”Now a denial we didn’t intend. Suppose the agent should list charges only for premium customers, and we add that to the filter:
filter: customerId == ${vars.customerId} and customerClass == "premium"submilli blueprint lint blueprint.yamlerror: blueprint.yaml: `permissions.main` rule 1 for `acme.com/charges.list` tests `customerClass`, which the operation doesn't report, so a condition on it is false for every call, and true under `not`; its fields are: customerIdLint refuses the file. Look at the capability list output again. The
operation reports one field, customerId. The Package never says what
class a customer is, so customerClass is missing from every call, and
a condition on a field that isn’t there is false, whatever the operator.
The rule could never match. submilli run doesn’t lint, so run the
legitimate program under the file as it is to see the denial such a
rule makes:
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.tserror: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop. fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main" at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]The second half of lint’s message is the other trap. Write the condition as an exclusion:
filter: customerId == ${vars.customerId} and not (customerClass == "standard")submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.ts2 charges, 6150 centsAllowed, because not of a false condition is true, so the rule
matches every call, premium or not. Lint refuses this form with the
same message. Write allow rules as conditions on fields the operation
reports, and remember that not matches when the field is missing. The
fix here belongs in the Package. An operation that should be
allowed by class has to report the class, looked up from the account, as
Export a function does. Restore
the filter before going on:
filter: customerId == ${vars.customerId}Decide: the rule or the program
Section titled “Decide: the rule or the program”| The denied call | What is wrong |
|---|---|
Asked for something the session isn’t for, like the cus_initech step |
Nothing. The rule did its job. The message tells the model to report and stop, and it should. |
| Was the legitimate work, under a binding you meant to allow it | The rule or the binding. Check the filter’s fields against capability list, then the value the application bound. |
Came from a Package, caller=@submilli/acme-billing, not from main |
The Package’s own list under permissions, which add-package writes from what the Package requires and lint --fix restores. |
Refer to Filter language for how a filter is evaluated, and to Errors and limits for every error a program can get.
You have read one denial all the way down, from the message to the rule that decided to the binding that made it decide that way. You have also seen the denial a dead rule makes, a filter on a field the operation doesn’t report, which lint refuses before it reaches a server. Next: Verify a Package in CI, then Manage Blueprints in Git, where the two runs you made by hand become a check on every pull request.