Skip to content

Add the Fleet Ops demo: multi-tenant fleet authorization on the Go SDK - #10

Open
Zivxx wants to merge 11 commits into
mainfrom
fleet-ops-demo
Open

Zivxx wants to merge 11 commits into
mainfrom
fleet-ops-demo

Conversation

@Zivxx

@Zivxx Zivxx commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

What this adds

fleet-ops-demo/: a multi-tenant fleet operations app on the Permit Go SDK (permit-golang v1.2.8). Customer
organizations own regions, sites, trucks and teams; partners get access to single trucks; a platform operator
("Fleet Ops") manages every organization. Every page and every write is decided by a local container PDP
(pdp-v2:0.9.16), and a panel beside each page shows every check, the PDP's answer and why: the relationship route,
the attribute rule's conditions with the values evaluated, the tenant-isolation verdict and the custom policy version.

It shows 17 scenarios (the README has the walkthrough): role derivation down a five-level tree, scoped admin
delegation, nested organizations, re-parenting, team supervisors, partner access on named trucks, owner vs.
operator, a broker across three organizations, certification-gated service, attribute-based resource sets,
tenant isolation, lifecycle gating, time-boxed break-glass access, revocation, organization merges and audit.

Most of it is Permit's built-in RBAC, ReBAC and ABAC. Tenant isolation and the owner/operator, certification,
lifecycle and time-boxed rules are custom Rego installed in the environment's GitOps branch by make apply-policy
and ANDed into every allow; the README says which scenario uses which.

Also: the folder's CI workflow (govulncheck, gofmt, vet, staticcheck, race tests, opa check and opa test), its
pre-commit hooks, a Dependabot entry for its Go module and the root README row.

How it was checked

  • The 16-step walkthrough, end to end, against a live Permit environment and container PDP, before the platform
    operator's tenant key was renamed. Every step behaved as the README describes; writes reached the PDP 0.6 to 0.8 s
    after the API call; the original 51 pre-flight checks (make verify; 53 now, with the two H1 rows) pass after a reset.
  • The review fixes (below): go test -race ./... (every package), 41/41 Rego tests on OPA 0.42.2 and on 1.21.1
    with --v0-compatible, staticcheck, govulncheck (0 reachable vulnerabilities), typos, gitleaks, actionlint and
    zizmor, all clean locally.
  • The owner/operator fix (H1) against a live PDP: the fixed rule loaded into a running pdp-v2:0.9.16 denies the
    two bypass cases with acting_org_mismatch, keeps the operator's own access, and the original 51 pre-flight checks
    still match.
  • Not run: this exact copy against a Permit environment from make setup. The tenant-key rename needs a fresh
    environment; the rename is mechanical, and the Rego and Go tests use the new key.

Review

Reviewed with two Go verifiers (idioms, risk) and one fixer pass. Every finding and what happened to it:

Finding Verdict What changed
Risk H1: a role the truck's owner granted counted while acting for the operating organization Fixed acting_org_ok requires the operator's own roles (operator_org_allow); 10 Rego tests, 2 new pre-flight rows (53 checks)
Risk H2: make setup/reset could act on a different environment than .env names Fixed .env key only (a different shell key is an error), no parent-folder lookup, cmd/sync resolves and prints the key's scope and refuses a mismatch
Idioms HIGH-1: failed PDP/OPA reads treated as "absent" Fixed Reads and waits return errors; "can't read" is reported apart from "missing"; no guessed reason when debug is unavailable
Idioms HIGH-2: actions reported success when the PDP never caught up Fixed Lagging writes say "Permit has the change, but the PDP doesn't confirm it"
Risk M1: tier 3 could be given from a stale copy of a person's attributes Fixed Attribute changes read Permit's current attributes inside the action
Risk M2: another site's page could drive writes through the localhost server Fixed Host check, http.CrossOriginProtection, JSON-only writes, ReadHeaderTimeout
Risk M3: the People page listed an organization's roster with every check denied Fixed Denied unless one of its checks is allowed
Risk M4: one write freezes every page; no request context Deferred Needs a context threaded through every API call and wait; releasing the lock changes the app's consistency model
Risk M5: writes continue when the custom rules drop out of root.rego Premise rejected, comments fixed Permit doesn't regenerate root.rego on schema or dashboard changes; only a base-policy update can overwrite it. Misleading comments corrected
Risk L1: "Remove from Permit" deletes the person everywhere Rejected That is the scenario ("nothing left behind"); not reachable with the seed
Risk L2: transfer/merge ignored wait results; create retried after a timeout Partly fixed Wait results reported (HIGH-2); create-retry idempotency deferred
Risk L3: some keys put into HTML attributes unescaped Fixed esc() on 12 interpolations
Risk L4: Rego's platform key not tied to the Go constant Fixed Test
Idioms MEDIUM-1: unwrapped errors Fixed About 57 call sites say what failed
Idioms MEDIUM-2: listRaw errors carried no status code Fixed permitapi.HTTPError, recognised by the retry logic
Idioms MEDIUM-3: no dependency audit; Go 1.23 out of support Fixed govulncheck job gating the Go job; Go 1.26 (toolchain go1.26.9)
Idioms MEDIUM-4: the root.rego contract tested on two separate fixtures Fixed One order-insensitive recogniser next to RootAllow, with a contract test
Idioms MEDIUM-5: logic packages without tests Partly fixed Tests for explain, permitapi, settings, checks, sync, pdp and server; cmd/verify, cmd/server and most action happy paths still untested
Idioms LOW-1: .env parsing differed from Compose Fixed Quotes and inline comments handled like Compose

For the reviewer

@zeevmoney, over to you:

  • The web UI is yours to replace; web/ only got escaping and a denied branch for the People page.
  • The README's "Notes on the Go SDK" and "Notes on the PDP" document real quirks (an SDK delete with swapped
    argument names, list calls that return nothing, a delete-and-recreate race in the PDP). Keep them here, or move
    them to tickets?
  • Every organization and person in the seed is fictional.
  • Needs Go 1.26 or later, and OPA 1.0 or later for make test.

🤖 Generated with Claude Code

Zivxx and others added 11 commits October 13, 2026 17:53
A Go app whose every page and write asks a local Permit PDP through the
Go SDK, with a panel that shows each check and why the PDP decided it.
It covers role derivation down a five-level tree, partner grants on
named trucks, attribute-based resource sets, tenant isolation, lifecycle
rules, time-boxed access, revocation and organization merges. The rules
Permit's editor can't express are custom Rego installed through GitOps.

Adds the folder's CI workflow (Go and Rego checks), its pre-commit
hooks, a Dependabot entry and the README row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t operates

Acting for the operating organization, any operational action on a truck it
operates passed the tenant-isolation rule, so a role the owner granted (which
reaches the truck through relationships whatever tenant is passed) allowed it:
Acme's Dallas technician could service DAL-04 by acting for Lone Star. Now only
a grant from the operator's own roles (operator_org_allow) opens such a truck,
and the denial explains which roles were missing.

Adds Rego tests that model root.rego's two allow rules with the generated
policy allowing, and two `make verify` rows (53 checks now).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…agging write from a done one

Reads of the PDP's data and policy turned every error into "nothing there".
So with OPA unreachable, WaitGone and WaitUnlinked reported the deleted data
gone after a millisecond, `make verify` said the custom rules were missing
and sent people to `make apply-policy`, and a denial without debug output
was explained as "No role, relationship or rule ... lets ...".

- pdp: OPAData, Debug, DebugMany, CustomPolicyVersion and the waits return
  errors. A 200 without a result (or a 404 module) is absent; anything else
  is an error. The waits return a *NotCaughtUpError when the PDP lags, so
  callers can tell lag from an unreadable PDP. Status reports RulesUnknown
  instead of "missing" when the policy couldn't be read.
- explain: Unavailable gives the verdict without a guessed reason.
- server: the waits after a write note what the PDP doesn't confirm, and the
  action then answers with that instead of its success message (the write
  itself stays done). A transfer still redirects.
- policy: RequiredByRoot (order-insensitive) and Version sit next to
  RootAllow, and a test ties apply-policy's patch to the reader.
- permitapi: HTTPError for calls made without the SDK, recognised by Status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`make setup` and `make reset` delete what the seed doesn't have, and they
could do it in an environment other than the one in .env: a PERMIT_API_KEY
exported in the shell won over .env (for the commands and for Compose), and
.env was looked up in every parent folder.

- settings: only the key in .env is used; a different one in the shell is an
  error. .env is looked up in the working directory and next to the program
  (and bin/'s parent), never further up. Load no longer writes the process
  environment (PORT is a setting). The parser reads quotes and " #" comments
  the way Docker Compose does, so the app and the PDP get the same key.
- sync: resolves the key's project and environment (/v2/api-key/scope),
  prints them before any write, stops when PERMIT_PROJECT or PERMIT_ENV names
  another, and lists set rules and condition sets in the key's own
  environment. Raw list errors are permitapi.HTTPError, so a 429 is retried
  and an allowed status applies.
- make pdp: unsets the shell's key so Compose takes the one in .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orted Go

CI built with the `go 1.23` directive, a release without security fixes, and
had no dependency audit, unlike the portal demo's workflow. govulncheck found
17 standard-library vulnerabilities the code reaches (HTTP server and
client), even on go1.26.4.

- go.mod: go 1.26.0, toolchain go1.26.9 (setup-go reads the toolchain line);
  govulncheck v1.8.0 then reports none the code reaches.
- workflow: a govulncheck job gates the Go job, which also runs staticcheck
  and the tests under -race. Both tools run as `go run module@version`,
  checked against the Go checksum database.
- errors.AsType where the code inspects error types.
- README: Go 1.26 or later, and `make test` needs opa 1.0 or later
  (--v0-compatible).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server listens on 127.0.0.1 and has no sign-in, but it took a POST with
any Content-Type, Origin and Host. A page open in the presenter's browser
could post a text/plain body (no CORS preflight) to delete an organization
as anyone, or rebind its host name to 127.0.0.1 to read the answers.

Every request must now name localhost or 127.0.0.1 as its host, writes go
through http.CrossOriginProtection, and an action must be JSON. The server
also gets a ReadHeaderTimeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on it

The page returned the acting organization's whole roster, with titles, roles
and certifications, whatever the bulk check answered: the checks only decided
the buttons. A broker or a partner technician acting for a customer saw its
staff and their grants.

The page is now denied, with the PDP's reason, unless at least one of its
checks (manage users, a team's activity, roles on a site) is allowed, and
the page shows the denial like the Access page does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not a stale copy

Certification and break-glass changes compared the request with the app's
in-memory copy of the person's attributes, read at start-up, and wrote that
copy back whole. After a reset or a dashboard edit while the app ran, an org
admin's tier 2 change could give tier 3 back without the platform-staff
check, and a break-glass grant could write stale certifications back.

Both actions now read the person's attributes from Permit inside the action,
decide which checks run against them, and write them back with only the one
attribute changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Person, organization, truck, site and role keys from the server went into
attributes and links unescaped, while every text field already went through
esc(). They are Permit keys or seed data, but a key with a quote in it would
have broken out of the attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments said schema writes regenerate the GitOps branch and that Permit
may regenerate root.rego after dashboard changes. Permit's policy sync does
neither: root.rego is replaced only when Permit merges a policy-library
update into the branch. The managed comment in root.rego also named the
demo's old folder.

Adds a test that custom.rego's platform org literal is model.PlatformOrg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
settings.Client silences the SDK's logger, which was the only place that
named the failing object, and most callers returned the SDK's error as is:
`sync failed: ErrorCode: UnexpectedError, ... 500 Internal Server Error`
didn't say which of about a hundred calls failed. Every API error in sync,
the app's start-up load and its actions now names what was being listed,
created, updated or deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Zivxx
Zivxx requested a review from zeevmoney October 13, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant