diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2422ff4..fe71ced 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,3 +35,15 @@ updates: update-types: [minor, patch] patterns: - '*' + - package-ecosystem: gomod + directory: /fleet-ops-demo + schedule: + interval: weekly + cooldown: + default-days: 7 + semver-major-days: 14 + groups: + go-dependencies: + update-types: [minor, patch] + patterns: + - '*' diff --git a/.github/workflows/fleet-ops-demo.yaml b/.github/workflows/fleet-ops-demo.yaml new file mode 100644 index 0000000..bde577a --- /dev/null +++ b/.github/workflows/fleet-ops-demo.yaml @@ -0,0 +1,100 @@ +name: fleet-ops-demo + +on: + push: + branches: [main] + paths: + - 'fleet-ops-demo/**' + - '.github/workflows/fleet-ops-demo.yaml' + pull_request: + paths: + - 'fleet-ops-demo/**' + - '.github/workflows/fleet-ops-demo.yaml' + +concurrency: + # A new push to a PR cancels that PR's superseded run. Pushes to main get a + # group per commit, so no merge's run is cancelled or replaced while queued. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: bash --noprofile --norc -euo pipefail {0} + working-directory: fleet-ops-demo + +jobs: + vuln: + name: Go vulnerability check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: fleet-ops-demo/go.mod + cache: false + # `go run module@version` checks the module against the Go checksum database before running it. + - name: Check the code and the toolchain for known vulnerabilities it reaches + run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + + go: + name: Go format, vet, build and tests + needs: vuln + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: fleet-ops-demo/go.mod + cache: false + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [[ -n "$unformatted" ]]; then + printf 'gofmt would change:\n%s\n' "$unformatted" + exit 1 + fi + - name: Vet + run: go vet ./... + - name: Build + run: go build ./... + - name: Staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 ./... + - name: Test + run: go test -race ./... + + rego: + name: Custom Rego checks and tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install verified opa binary + run: | + curl --fail --silent --show-error --location --max-time 60 \ + https://github.com/open-policy-agent/opa/releases/download/v1.21.1/opa_linux_amd64_static \ + --output "$RUNNER_TEMP/opa" + printf '%s %s\n' \ + '668506eb17a2eaa1fce6cc0d1f42ef85125d4ac5bda5fc74d1152d0c77145031' \ + "$RUNNER_TEMP/opa" | sha256sum --check + chmod +x "$RUNNER_TEMP/opa" + # The rules are written in the Rego v0 syntax that Permit's generated policy uses. + - name: Check and test the custom rules + run: | + "$RUNNER_TEMP/opa" check --v0-compatible policy/ + "$RUNNER_TEMP/opa" test --v0-compatible policy/ diff --git a/README.md b/README.md index 91aadf8..8c86668 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Runnable applications that use [Permit.io](https://www.permit.io) for authorizat lives in its own folder, with a README that explains how to run it and a CI workflow that runs only when that folder changes. -| Example | What it shows | Stack | -| ------------------------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------- | -| [permit-portal-demo](permit-portal-demo) | A business portal whose every protected action asks Permit first, with five scenarios | TypeScript, Express, React, Docker, local PDP | +| Example | What it shows | Stack | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| [permit-portal-demo](permit-portal-demo) | A business portal whose every protected action asks Permit first, with five scenarios | TypeScript, Express, React, Docker, local PDP | +| [fleet-ops-demo](fleet-ops-demo) | A multi-tenant fleet app: roles down a 5-level tree, partner grants, attribute rules and tenant isolation | Go, Permit Go SDK, custom Rego, local PDP | ## Checks diff --git a/fleet-ops-demo/.env.example b/fleet-ops-demo/.env.example new file mode 100644 index 0000000..28cd2d7 --- /dev/null +++ b/fleet-ops-demo/.env.example @@ -0,0 +1,12 @@ +# Copy to .env (gitignored) and fill in the key. +# An environment-scoped API key from the Permit dashboard. The setup, the app and the PDP use it, and +# only this one: a different PERMIT_API_KEY exported in your shell stops the commands. +PERMIT_API_KEY= +PERMIT_API_URL=https://api.permit.io +# Optional: the project and environment keys. `make setup` and `make reset` stop when the API key +# belongs to another environment; the app shows them on its start line. +PERMIT_PROJECT= +PERMIT_ENV= +PDP_URL=http://localhost:7766 +OPA_URL=http://localhost:8181 +PORT=3000 diff --git a/fleet-ops-demo/.gitignore b/fleet-ops-demo/.gitignore new file mode 100644 index 0000000..ee97b02 --- /dev/null +++ b/fleet-ops-demo/.gitignore @@ -0,0 +1,4 @@ +.env +bin/ +# A clone of the Permit GitOps environment branch, for `make apply-policy`. +policy-repo/ diff --git a/fleet-ops-demo/.pre-commit-config.yaml b/fleet-ops-demo/.pre-commit-config.yaml new file mode 100644 index 0000000..c622582 --- /dev/null +++ b/fleet-ops-demo/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +# prek runs these hooks from this folder, on this folder's files. The repository-wide +# hooks (secrets, workflows, shell scripts) live in the root .pre-commit-config.yaml. +repos: + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: sh -c 'test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }' + language: system + pass_filenames: false + types: [go] + - id: go-vet + name: go vet + entry: go vet ./... + language: system + pass_filenames: false + types: [go] + - id: go-test + name: go test + entry: go test ./... + language: system + pass_filenames: false + types: [go] + - id: opa-test + name: Custom Rego tests + entry: opa test --v0-compatible policy/ + language: system + pass_filenames: false + files: ^policy/.*\.rego$ diff --git a/fleet-ops-demo/Makefile b/fleet-ops-demo/Makefile new file mode 100644 index 0000000..122d712 --- /dev/null +++ b/fleet-ops-demo/Makefile @@ -0,0 +1,29 @@ +# Fleet Ops: a multi-tenant fleet app on the Permit Go SDK. Needs Docker and Go; `make test` also +# needs the opa CLI. Every command reads .env in this folder (copy .env.example). +.PHONY: pdp setup reset build start verify apply-policy test + +# Compose lets a PERMIT_API_KEY exported in the shell override .env; the PDP takes the key in .env. +pdp: ## start the local container PDP (port 7766) + env -u PERMIT_API_KEY docker compose --env-file .env up -d pdp + +setup: ## make Permit's schema match the code, then restore the seed data + go run ./cmd/sync + +reset: ## restore the seeded data in Permit; restart the app afterwards + go run ./cmd/sync --reset + +build: ## build the server into bin/ + go build -o bin/fleetops-server ./cmd/server + +start: build ## run the app on http://localhost:3000 + ./bin/fleetops-server + +verify: ## pre-flight: PDP up, custom rules loaded, expected decisions + go run ./cmd/verify + +apply-policy: ## install the custom rules in the GitOps branch and restart the PDP + go run ./cmd/apply-policy + +test: ## unit tests: Go, and the custom Rego + go test ./... + opa test --v0-compatible policy/ diff --git a/fleet-ops-demo/README.md b/fleet-ops-demo/README.md new file mode 100644 index 0000000..e13fda0 --- /dev/null +++ b/fleet-ops-demo/README.md @@ -0,0 +1,236 @@ +# Fleet Ops: multi-tenant fleet authorization with the Permit Go SDK + +Fleet Ops is a small multi-tenant fleet operations app. Customer organizations own regions, sites, +trucks and teams; partner organizations get access to single trucks; a platform operator manages +every organization. Every page and every write is decided by a Permit PDP container running next to +the app, through the Permit Go SDK (`github.com/permitio/permit-golang` v1.2.8). + +You sign in as a person, pick the organization you act for, and work with trucks, sites and people. +The acting organization goes into every check as its tenant. A panel beside each page lists every +check the backend sent, with 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. + +## What it shows + +| # | Scenario | How it is built | +|---|---|---| +| 1 | Role derivation down the tree: a region manager reaches every site and truck in the region; a site manager stops at the site | Built into Permit (ReBAC) | +| 2 | Scoped admin delegation: a region manager may give roles on any site of the region, a site manager only on their own site | Built into Permit (ReBAC) | +| 3 | Nested organizations: a group admin reaches both sub-organizations; a sub-organization's admin sees nothing in the other | Built into Permit (ReBAC) | +| 4 | Re-parenting: moving a truck to another site replaces one parent link, and derived access follows | Built into Permit (ReBAC) | +| 5 | Team supervisor: sees the activity of their own team's members only | Built into Permit (ReBAC) | +| 6 | Partner access on named trucks, and a transfer that doesn't carry the grant along | Built in, with custom Rego | +| 7 | Owner vs. operator: the operating organization gets operational actions, the owner keeps ownership actions | Custom Rego | +| 8 | Aggregated read-only: a broker sees one shared truck from each of three organizations, and nothing else | Built into Permit (ReBAC) | +| 9 | Certification-gated service; only platform staff may give tier 3 | Built in, with custom Rego | +| 10 | A portable certification with per-organization access | Custom Rego | +| 11 | Attribute-based resource sets ("region = South", "served account = Acct1") granted to org-level roles | Built into Permit (ABAC) | +| 12 | An attribute rule never reaches across organizations | Custom Rego | +| 13 | Lifecycle gating: unassigned and decommissioned trucks | Custom Rego | +| 14 | Time-boxed break-glass access that ends on its own | Custom Rego | +| 15 | Revocation: a partner grant, a person and a whole organization, with nothing left behind | Built into Permit | +| 16 | Merging one organization into another as a sub-organization | Built into Permit | +| 17 | Audit: every decision's reason and policy version, in the app and in Permit's Audit Log | Built in, with custom Rego | + +"Custom Rego" means rules Permit's policy editor can't express, kept in the environment's GitOps +repository (`policy/custom.rego`); every allow must also satisfy them. They are configuration, not +out-of-the-box behavior. + +## Run it + +You need Docker, Go 1.26 or later, a Permit project with an empty environment, and, for the custom +rules, a Git repository connected to that environment through +[GitOps](https://docs.permit.io/integrations/gitops/github). `make test` also needs the +[opa](https://www.openpolicyagent.org/docs/latest/#running-opa) CLI, version 1.0 or later. + +1. Copy `.env.example` to `.env` and put the environment's API key in `PERMIT_API_KEY`. The key + stays on the server; the browser never sees it. `make setup` and `make reset` name the + environment the key belongs to before they change anything, and stop if `PERMIT_PROJECT` or + `PERMIT_ENV` in `.env` names another one. +2. Start the PDP, then create the schema and the seed data in Permit: + ```bash + make pdp + make setup + ``` +3. Install the custom rules. Clone the environment's GitOps branch into `policy-repo/` (gitignored), + with push access, then apply: + ```bash + git clone --branch permit/generated/ policy-repo + make apply-policy + ``` + It writes `custom/root.rego`, makes every allow in `root.rego` require it, pushes, restarts the + PDP and waits until the PDP has loaded the new version. +4. Check, then start the app on http://localhost:3000: + ```bash + make verify + make start + ``` + `make verify` should end with "All checks match" (53 checks). In the app, the top of the right + panel should say "Local PDP connected" and "Custom rules loaded". + +| Command | What it does | +|---|---| +| `make pdp` | Start the local container PDP (port 7766) from `docker-compose.yml` | +| `make setup` | Make Permit's schema match `internal/schema` exactly (create what is missing, remove what it doesn't have), then restore the seed | +| `make reset` | Restore the seeded data in Permit (organizations, people, instances, relationships, role assignments, people's attributes); restart the app afterwards | +| `make apply-policy` | Install the custom rules in the GitOps branch, restart the PDP and wait for the new version | +| `make verify` | Pre-flight: PDP up, custom rules loaded, the 53 decisions the scenarios rely on | +| `make start` | Build and run the app on http://localhost:3000 | +| `make test` | Go unit tests, and the custom Rego's unit tests (`opa test --v0-compatible policy/`) | + +Steps 13 to 16 of the walkthrough change data for good. Afterwards, stop the app, run `make reset` +and start it again: the app reads its data from Permit when it starts. + +## The model + +| Resource | Parent | Actions | Roles on it | +|---|---|---|---| +| org | parent org (sub-orgs) | manage_users, assign_roles | org_admin | +| region | parent org | view, assign_roles | region_manager | +| site | parent region | view, assign_roles | site_manager, operator, technician, viewer | +| asset (truck) | parent site | view, operate, service_basic, service_advanced, decommission | site_manager, operator, technician, viewer, partner_technician | +| team | parent site | view_activity | team_supervisor | + +- **Derivation down the tree.** An org admin is org admin of every sub-org, and region manager of + the org's regions. A region manager is site manager of the region's sites. Each site role carries + down to the site's trucks under the same name. On a truck, the site manager can view and + decommission, an operator operates, and a technician services. `partner_technician` and + `team_supervisor` are given directly and never derived. +- **Attributes.** User: `certifications` (tier1 to tier3), `home_tenant` and `breakglass` (the + time-boxed grants, each with `tenant`, `incident_id` and `expires_at`: Permit can't put attributes + on a role assignment). Asset: `region`, `served_account`, `lifecycle_state` (unassigned, active, + transferring, decommissioned), `owner_org` and `operator_org`. +- **Attribute-based sets.** "Assets where region = South" and "Assets where served_account = Acct1". + The `operator` and `viewer` roles, given at org level, hold permissions on those sets and nothing + else. +- **The platform operator.** Fleet Ops is an organization of its own; its org admins are platform + staff. Platform-only actions (giving tier 3, transferring a truck to another organization, + deleting or merging an organization) are checked as `manage_users` on its organization. + +## People and trucks + +The sign-in menu groups people by employer. All the organizations and people are fictional. + +| Person | Access | +|---|---| +| Ava Stone (Fleet Ops) | Platform operator: org admin of Fleet Ops (platform staff) and of every customer organization | +| Olivia Chen | Org admin, Acme Freight | +| Rachel Reyes | Region manager, Acme South (Dallas and Houston) | +| Sam Ortiz | Site manager, Dallas; supervisor of the Dallas night shift | +| Ivy Walker | Technician at Dallas, tier 1; on the night shift | +| Quinn Ellis | Org-level operator: Acme's trucks in region South; on the day shift | +| Riley Shaw | Org-level viewer: Acme's trucks serving Acct1 | +| Lena Park | Org admin, Lone Star Logistics | +| Avery Cole | Org admin, Summit Freight Group, the parent of Summit East and Summit West | +| Jamie Ortega | Org admin of Summit East only | +| Pat Morgan (Mesa) | Partner technician on DAL-01 and DAL-02 only; tier 1 and 2 | +| Jordan Lee (Mesa) | Technician at Acme's Dallas and Lone Star's Fort Worth, tier 3 | +| Skyler Grant (Freightline) | Broker: viewer on one truck each from Acme, Lone Star and Summit | + +Acme has DAL-01 to DAL-04 (Dallas), HOU-01 (Houston), PHX-01 and PHX-02 (Phoenix). DAL-02 serves +Acct2 and the others Acct1. DAL-03 is decommissioned, DAL-04 is operated by Lone Star, PHX-02 is +unassigned, and the Phoenix trucks are in region West. Lone Star has FTW-01 (region South). Summit has +ATL-01 (Summit East) and DEN-01 (Summit West). + +## Walkthrough + +Use **Signed in as** and **Organization** in the top bar; switching either one re-checks the page +you are on. Steps 13 to 16 change data, so they come last. + +| # | Scenario | Do | Point at in the panel | +|---|---|---|---| +| 1 | 1. Role derivation | Sign in as **Rachel**: the fleet shows Dallas and Houston's five trucks. Open **HOU-01**. Switch to **Sam** on the same page | Rachel's ALLOW. Why: region manager on South, then site manager on Houston terminal, then site manager on HOU-01. Sam: DENY, a site role stops at its site | +| 2 | 2. Scoped admin delegation | As **Sam**, open **People**, Give a role: Ivy Walker, Operator, **Houston terminal**: denied. Switch to **Rachel** and give the same role | Sam: DENY on `assign_roles` for Houston. Rachel: ALLOW, then "Can Ivy Walker operate Truck HOU-01 now?" ALLOW | +| 3 | 4. Re-parenting | As **Rachel**, open **DAL-02**, Fleet management, **Move to this site**: Houston terminal | Two site-admin checks, one parent link replaced, no role changed. Then Sam: DENY; Ivy (operator at Houston): ALLOW | +| 4 | 5. Team supervisor | Sign in as **Sam**, open **People** | `view_activity` allowed on the night shift (Ivy's activity shows), denied on the day shift (Quinn's doesn't) | +| 5 | 3. Nested organizations | Sign in as **Avery**: ATL-01 and DEN-01. Open **DEN-01**. Sign in as **Jamie**: ATL-01 only | Avery's route: org admin on Summit Freight Group, Summit West, West, Denver terminal, DEN-01. Jamie's DEN-01 check denies | +| 6 | 9. Certifications | Sign in as **Ivy**, open **DAL-01**: basic service only. Sign in as **Olivia**, **People**, tick Tier 3 for Ivy, **Save certifications**: denied. Tick Tier 2 instead: saved. Sign in as **Ava**, organization **Acme Freight**, **People**, give Ivy Tier 3 | Olivia's tier 3: DENY on `manage_users` for Fleet Ops (platform staff only). Tier 2: ALLOW on Acme. Ava: ALLOW, then "Can Ivy Walker do advanced service on DAL-01 now?" ALLOW | +| 7 | 10. Portable certification | Sign in as **Jordan**, organization **Acme Freight**: Run advanced service on **DAL-01**. Organization **Lone Star Logistics**: Run advanced service on **FTW-01**. Still acting for Lone Star, go to `#/truck/acme-dal-01` | Same certification in both. The last check: DENY, `acting_org_mismatch` | +| 8 | 11. Attribute-based sets | Sign in as **Quinn**: Acme's South trucks, no Phoenix. Sign in as **Riley**: the Acct1 trucks, no DAL-02. As **Rachel**, open **DAL-02**, Details: served account Acct1, **Save details**. Then region West | "Can Riley Shaw see Truck DAL-02 now?" ALLOW. "Can Quinn Ellis operate Truck DAL-02 now?" DENY, with the unmet region condition | +| 9 | 12. Attributes never cross organizations | As **Quinn** (Acme), go to `#/truck/lonestar-ftw-01`: Lone Star's truck is in region South too | DENY with `acting_org_mismatch`: the attribute rule would match, the tenant rule stops it | +| 10 | 7. Owner vs. operator | Sign in as **Lena**: FTW-01 and DAL-04, "Operated by Lone Star". Open **DAL-04**: Dispatch, no Decommission. As **Sam**: Decommission. As **Quinn**: no Dispatch | Lena's allow: "Lone Star Logistics operates it for Acme Freight". Quinn's deny: `operated_by_other_org` | +| 11 | 8. Aggregated read-only | Sign in as **Skyler**, organization **All my organizations**: HOU-01, FTW-01, ATL-01. Open one: view only | One bulk check per organization, each acting for its own organization | +| 12 | 13. Lifecycle gating | As **Ava** acting for Acme, the fleet has 7 trucks including **PHX-02** (unassigned). **Olivia** sees 6. Open **DAL-03** (decommissioned) as **Ivy**: no service. As **Sam**: Reactivate | The `unassigned` and `decommissioned` fleet rules in Why | +| 13 | 6. Partner access, then a transfer | Sign in as **Pat**, organization **Acme Freight**: DAL-01 and DAL-02 only. Sign in as **Ava** (Acme), open **DAL-01**, **Transfer to this organization**: Lone Star Logistics, Fort Worth terminal | "After the transfer: can Pat Morgan still view Truck DAL-01": denied acting for Acme (`acting_org_mismatch`) and for Lone Star (no grant) | +| 14 | 14. Time-boxed access | As **Olivia**, open **Access**, give **Pat** break-glass for INC-1042, 2 minutes. As **Pat** acting for Acme, every Acme truck except the unassigned PHX-02; 2 minutes later only DAL-02 | "During incident INC-1042: can Pat Morgan view …" ALLOW, "under a break-glass grant" | +| 15 | 15. Revocation | As **Olivia**, open **DAL-02**, Partner access, **Revoke** Pat. **People**, **Remove from Permit**: Riley. As **Ava**, open **Organizations**, **Delete** Summit Freight Group | Each shows a before ALLOW and an after DENY, and what Permit still holds: 0 | +| 16 | 16. Merge | As **Ava**, **Organizations**: **Merge** Lone Star Logistics into Acme Freight (about 15 seconds) | Lena keeps her role, now acting for Acme; Olivia reaches Lone Star's trucks through the new sub-organization link | + +**17. Audit**, along the way: any check's **Why**, its **PDP response**, and Permit's **Audit Log** +filtered by user. The decision record carries `debug.acting_org` (`same_org` or +`acting_org_mismatch` and its reason) and `debug.fleet_rules.policy_version`. + +## Questions you may get + +- **Is tenant isolation out of the box?** No, it is configuration. The PDP takes the tenant in a + check as given: relationship-derived roles apply whatever tenant is passed, and org-level roles and + attribute rules are read in the tenant passed, without comparing it with the resource's own + organization. The custom rule adds that comparison and fails closed: a check about a resource the + PDP doesn't know is denied (`unknown_resource`). +- **Why does a truck have a site manager role?** Permit keeps a resource's permissions on that + resource's own roles. So each site role is also a role on trucks, derived from the truck's site. +- **Why are operator and viewer also org-level roles?** Attribute rules grant permissions to user + sets, and a user set can require an org-level role but not a relationship-derived one. +- **Does the tenant rule cover lists?** It covers `/allowed`, `/allowed/bulk` and AuthZEN + `/access/v1/evaluation`. `/user-permissions` and AuthZEN search don't evaluate the root policy, so + the app builds its lists with `BulkCheck`. +- **Can an instance move to another organization?** Not in place: a resource instance's tenant is + fixed. A transfer or a merge deletes the instances and creates them again in the new organization; + their old links and grants go with the deletion. +- **Where do the reasons in the panel come from?** The SDK's `Check()` and `BulkCheck()` return + booleans, as an application uses them. For the panel, the app sends the same checks again to the + PDP's `/allowed` or `/allowed/bulk` and words the debug output it returns. +- **Is the app enforcing, or just hiding buttons?** Both. Pages show only what the PDP allows, and + every write is checked again on the server before anything is written. + +## How it's built + +| Path | What it is | +|---|---| +| `cmd/server`, `internal/server` | The app: loads authorization data from Permit at start, asks the PDP before showing anything, checks every write again before making it, writes through the SDK's API client, and returns each check for the panel. Pages: fleet, truck, site, people, access, organizations | +| `web/` | The page (`index.html`, `app.js`, `styles.css`), built into the binary | +| `internal/schema` | The Permit schema: resource types, actions, roles and derivations, the two org-level roles and the attribute rules | +| `internal/model` | Seed data: 5 organizations (the platform operator and four customers), 13 people, 30 resources, 25 parent links, 22 role assignments, team members and starting activity | +| `policy/` | The custom Rego (`custom.rego`) and its unit tests; `make apply-policy` installs it as `custom/root.rego` | +| `cmd/sync` | `make setup` makes Permit's schema match the code and restores the seed; `make reset` (`--reset`) restores the seed only | +| `internal/pdp` | `permitClient.Check()` and `permitClient.BulkCheck()`, the PDP debug requests, status, and waits on the PDP's data | +| `internal/explain` | Turns the PDP's debug output into the reason, route, rule conditions and fleet rules in the panel | +| `internal/checks`, `cmd/verify` | The 53 pre-flight checks behind `make verify` | +| `cmd/apply-policy` | Installs the custom rules in the GitOps branch, restarts the PDP and waits for the new version | +| `internal/permitapi` | A timeout, retries and paging around every API call | + +### Where the SDK is used + +- Decisions: `permitClient.Check` and `permitClient.BulkCheck` in + [`internal/pdp/pdp.go`](internal/pdp/pdp.go). +- Writes from the app: `Api.ResourceInstances`, `Api.RelationshipTuples`, `Api.Users` (assign and + unassign resource roles, update attributes, delete), `Api.Tenants.Delete` and + `Api.Roles.BulkAssignRole` in [`internal/server`](internal/server). +- Setup and reset: `Api.Resources`, `ResourceActions`, `ResourceAttributes`, `ResourceRelations`, + `ResourceRoles`, `ImplicitGrants`, `Roles`, `ConditionSets`, `Tenants`, `Users.SyncUser`, + `Roles.BulkAssignRole` and `BulkUnAssignRole` in [`cmd/sync/main.go`](cmd/sync/main.go), with + their `Delete` methods to remove what the schema or the seed doesn't have. + +### Notes on the Go SDK (v1.2.8) + +- `BulkCheck` splits the checks by tenant and sends one `/allowed/bulk` request per tenant. +- `ResourceRoleUpdate` has no `granted_to`, so role derivations are created one rule at a time with + `Api.ImplicitGrants.Create`, and removed with `Api.ImplicitGrants.Delete`. `Delete` names its + arguments `(roleId, resourceId)` but builds the URL from them as resource then role, the same order + as `Create`. +- `ConditionSets.ListSetPermissions` always sends its three filters, and empty filters match + nothing; `ConditionSets.List` expects a paginated answer and reads the API's plain list as empty. + `cmd/sync` reads both collections from the REST API instead. +- The facts API clients (resource instances, relationship tuples, role assignments) have no HTTP + timeout. Every API call here runs with a 20-second timeout and retries. + +### Notes on the PDP (pdp-v2 0.9.16) + +- After an instance is deleted, the PDP keeps a stub without a tenant. The tenant-isolation rule + therefore fails closed for unknown instances. +- Deleting an instance and creating it again with the same key (transfer, merge, reset) can race: + the deletion of the old links can reach the PDP after the new data. The app and `make reset` wait + until the PDP has dropped the old instance before creating it again, then require every parent + link to stay in the PDP, refetching its data when some are missing. +- A schema change leaves the relationship cache that `/user-permissions` and AuthZEN search use + incomplete until it is rebuilt; `make setup` rebuilds it. diff --git a/fleet-ops-demo/cmd/apply-policy/main.go b/fleet-ops-demo/cmd/apply-policy/main.go new file mode 100644 index 0000000..dd88399 --- /dev/null +++ b/fleet-ops-demo/cmd/apply-policy/main.go @@ -0,0 +1,143 @@ +// Command apply-policy installs this demo's custom rules in the GitOps environment branch: the custom +// Rego in policy/custom.rego becomes custom/root.rego (stamped with a new version when it changes), +// and root.rego's allow rules require it. Then it restarts the PDP so it loads the policy at once +// (Permit polls customer repos; a restart is faster than waiting). Changes nothing in Git when both +// are already in place. +// +// make apply-policy +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "fleetops/internal/pdp" + "fleetops/internal/settings" + "fleetops/policy" +) + +func main() { + os.Exit(run()) +} + +func run() int { + s := settings.MustLoad() + repo := filepath.Join(s.DemoDir, "policy-repo") + git := func(args ...string) error { + out, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return nil + } + fail := func(err error) int { + fmt.Fprintln(os.Stderr, err) + return 1 + } + + if err := git("pull", "--ff-only", "-q"); err != nil { + return fail(err) + } + + changed := false + customPath := filepath.Join(repo, "custom", "root.rego") + installed, err := os.ReadFile(customPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { // not there yet: installed below + return fail(err) + } + version := policy.Version(string(installed)) + if !policy.SameRules(string(installed)) { + version = time.Now().UTC().Format("2006-01-02T15:04:05Z") + if err := os.WriteFile(customPath, []byte(policy.Custom(version)), 0o644); err != nil { + return fail(err) + } + fmt.Printf("custom/root.rego: installed the custom rules, policy version %s.\n", version) + changed = true + } + + rootPath := filepath.Join(repo, "root.rego") + data, err := os.ReadFile(rootPath) + if err != nil { + return fail(err) + } + root, err := patchRoot(string(data)) + if err != nil { + return fail(err) + } + if root != string(data) { + if err := os.WriteFile(rootPath, []byte(root), 0o644); err != nil { + return fail(err) + } + fmt.Println("root.rego: every allow now requires the custom rules.") + changed = true + } + + if changed { + for _, args := range [][]string{ + {"add", "custom/root.rego", "root.rego"}, + {"commit", "-q", "-m", "Install the custom rules (tenant isolation and fleet rules for the demo's scenarios) and require them in root.rego"}, + {"push", "-q", "origin", "HEAD"}, + } { + if err := git(args...); err != nil { + return fail(err) + } + } + fmt.Println("Pushed to the environment branch.") + } else { + fmt.Println("The custom rules are already installed and required.") + } + + fmt.Println("Restarting the PDP so it loads the policy now…") + client := pdp.New(s, settings.Client(s)) + if err := client.Restart(); err != nil { + return fail(err) + } + // Permit fetches the push from Git on its own schedule, so the restarted PDP may first load the + // previous rules: wait for this version. + var readErr error // the last failed read of the PDP's policy, if the last attempt failed + for range 60 { + time.Sleep(3 * time.Second) + st := client.Status() + if st.RulesUnknown != "" { + readErr = errors.New(st.RulesUnknown) + continue + } + loaded, err := client.CustomPolicyVersion() + if readErr = err; err != nil { + continue + } + if st.PDP && st.ActingOrgRule && loaded == version { + fmt.Printf("The PDP has loaded the custom rules, version %s. Run `make verify`.\n", version) + return 0 + } + } + if readErr != nil { + return fail(fmt.Errorf("couldn't read the PDP's policy for three minutes (%w); check OPA_URL in .env, then run `make apply-policy` again", readErr)) + } + return fail(fmt.Errorf("the PDP has not loaded custom rules version %s after three minutes; run `make apply-policy` again, or check `docker compose logs pdp` in the demo folder", version)) +} + +var debugResult = regexp.MustCompile(`(?m)^(\s*)result := .*debug\.debug.*$`) + +// patchRoot rewrites root.rego's allow rules (everything between `default allow := false` and +// `allowing_sources :=`) and the debug result line. It works on Permit's generated file and on an +// already patched one alike. +func patchRoot(root string) (string, error) { + const marker = "default allow := false\n" + start, end := strings.Index(root, marker), strings.Index(root, "\nallowing_sources :=") + if start < 0 || end < start { + return "", fmt.Errorf("root.rego doesn't have the expected layout (`default allow := false` ... `allowing_sources :=`); edit it by hand: every allow rule must also require custom.acting_org_ok and custom.fleet_rules_ok") + } + root = root[:start+len(marker)] + "\n" + policy.RootAllow + "\n" + root[end:] + if !debugResult.MatchString(root) { + return "", fmt.Errorf("root.rego has no `result := ... debug.debug` line to extend") + } + return debugResult.ReplaceAllString(root, "${1}"+policy.RootDebug), nil +} diff --git a/fleet-ops-demo/cmd/apply-policy/main_test.go b/fleet-ops-demo/cmd/apply-policy/main_test.go new file mode 100644 index 0000000..3ba45fc --- /dev/null +++ b/fleet-ops-demo/cmd/apply-policy/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "strings" + "testing" + + "fleetops/policy" +) + +const generatedRoot = `package permit.root + +import data.permit.custom +import data.permit.debug +import data.permit.policies + +default allow := false + +allow { + policies.allow +} + +allow { + custom.allow +} + +allowing_sources := policies.__allow_sources + +debug = result { + debugger_activated + result := debug.debug +} +` + +const isolationOnlyRoot = `package permit.root + +default allow := false + +# An earlier patch that required tenant isolation only. +allow { + policies.allow + custom.acting_org_ok +} + +allowing_sources := policies.__allow_sources + +debug = result { + debugger_activated + result := object.union(debug.debug, {"acting_org": custom.acting_org_debug}) +} +` + +func TestPatchRoot(t *testing.T) { + for name, root := range map[string]string{"generated": generatedRoot, "isolation-only": isolationOnlyRoot} { + t.Run(name, func(t *testing.T) { + patched, err := patchRoot(root) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(patched, policy.RootAllow) || !strings.Contains(patched, policy.RootDebug) { + t.Fatalf("patched root.rego misses the managed rules:\n%s", patched) + } + if strings.Contains(patched, "custom.allow\n") || strings.Count(patched, "\nallow {") != 2 { + t.Fatalf("expected exactly the two managed allow rules:\n%s", patched) + } + // The reader `make verify` and the app use must recognize what the writer installs. + if !policy.RequiredByRoot(patched) { + t.Fatalf("policy.RequiredByRoot doesn't recognize the patched root.rego:\n%s", patched) + } + if policy.RequiredByRoot(root) { + t.Fatal("policy.RequiredByRoot recognizes root.rego before the patch") + } + again, err := patchRoot(patched) + if err != nil || again != patched { + t.Fatalf("patching twice changed the file (err %v)", err) + } + }) + } + if _, err := patchRoot("package permit.root\n"); err == nil { + t.Fatal("expected an error for an unexpected layout") + } +} + +func TestSameRules(t *testing.T) { + installed := policy.Custom("2026-10-11T17:00:00Z") + if !policy.SameRules(installed) { + t.Fatal("an installed copy with another version should count as the same rules") + } + if policy.SameRules(installed + "\n# edited\n") { + t.Fatal("an edited copy should not count as the same rules") + } +} diff --git a/fleet-ops-demo/cmd/server/main.go b/fleet-ops-demo/cmd/server/main.go new file mode 100644 index 0000000..008b964 --- /dev/null +++ b/fleet-ops-demo/cmd/server/main.go @@ -0,0 +1,29 @@ +// Command server runs Fleet Ops on http://localhost:3000 (PORT in .env changes it). +// +// make start +package main + +import ( + "fmt" + "log" + "net/http" + "time" + + "fleetops/internal/pdp" + "fleetops/internal/server" + "fleetops/internal/settings" + "fleetops/web" +) + +func main() { + s := settings.MustLoad() + app := server.New(pdp.New(s, settings.Client(s))) + if err := app.Load(); err != nil { + log.Fatalf("loading authorization data from Permit: %v", err) + } + port := s.Port + addr := "127.0.0.1:" + port + fmt.Printf("Fleet Ops (Go SDK) on http://localhost:%s, project %s, environment %s\n", port, s.Project, s.Env) + srv := &http.Server{Addr: addr, Handler: app.Handler(web.Files, s.Project, s.Env), ReadHeaderTimeout: 10 * time.Second} + log.Fatal(srv.ListenAndServe()) +} diff --git a/fleet-ops-demo/cmd/sync/main.go b/fleet-ops-demo/cmd/sync/main.go new file mode 100644 index 0000000..e6105b9 --- /dev/null +++ b/fleet-ops-demo/cmd/sync/main.go @@ -0,0 +1,938 @@ +// Command sync writes the Fleet Ops schema and seed data to Permit through the Go SDK. +// +// make setup make the schema match internal/schema exactly, then restore the seed data +// make reset data only: remove what the app added and restore the seed +// +// The schema converges both ways: what is missing is created, what differs is updated, and what +// internal/schema doesn't have is removed (resource types, actions, attributes, relations, roles, +// derivations, org-level roles, condition sets and their rules), dashboard changes included. Schema +// writes update Permit's generated policy in the GitOps branch, but not root.rego or +// custom/root.rego, so the custom rules `make apply-policy` installed stay in force. A reset never +// touches the schema. +// +// After a setup the command waits until the local PDP has the schema, then rebuilds the PDP's +// relationship cache: on pdp-v2 0.9.16 a schema change leaves that cache (used by /user-permissions +// and AuthZEN search) missing relationships until it is rebuilt. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "os" + "reflect" + "slices" + "strings" + "time" + + "github.com/permitio/permit-golang/pkg/api" + "github.com/permitio/permit-golang/pkg/models" + + "fleetops/internal/model" + "fleetops/internal/pdp" + "fleetops/internal/permitapi" + "fleetops/internal/schema" + "fleetops/internal/settings" +) + +func main() { + reset := flag.Bool("reset", false, "data only: remove what the app added and restore the seed") + flag.Parse() + + s := settings.MustLoad() + client := settings.Client(s) + api := rest{url: s.APIURL, key: s.APIKey} + suffix := "" + if *reset { + suffix = " (reset)" + } + // Both commands delete what the seed doesn't have: name the environment first, and stop if .env + // names another one. + scope, err := scopeOf(api) + if err != nil { + fail(err) + } + fmt.Printf("Permit project=%s env=%s (%s)%s\n", scope.ProjectKey, scope.EnvKey, scope.EnvName, suffix) + if err := scope.check(s.Project, s.Env); err != nil { + fail(err) + } + if !*reset { + if err := syncSchema(client.Api, api, scope); err != nil { + fail(err) + } + } + p := pdp.New(s, client) + pdpUp := p.Status().PDP + // A late deletion of what these waits watch can strip links created again with the same keys; + // ensureLinks below checks and repairs the links, so a failed wait only warns. + waitGone := func(ids []string) { + if !pdpUp { + return + } + if err := p.WaitGone(ids, 30*time.Second); err != nil { + logf("couldn't confirm the PDP dropped the deleted instances (%v); continuing", err) + } + } + waitUnlinked := func(ids []string) { + if !pdpUp { + return + } + if err := p.WaitUnlinked(ids, 30*time.Second); err != nil { + logf("couldn't confirm the PDP dropped the deleted links (%v); continuing", err) + } + } + var returned []string + churn, err := syncFacts(client.Api, &returned, waitGone, waitUnlinked) + if err != nil { + fail(err) + } + if len(returned) > 0 && pdpUp { + // Instances deleted and created again: make sure the PDP didn't lose their new parent links. + links := map[string]string{} + for _, id := range returned { + for _, t := range model.Tuples { + if t.Object == id && t.Relation == "parent" { + links[id] = t.Subject + } + } + } + if err := p.Settle(links); err != nil { + logf("couldn't confirm the PDP has the returned instances' parent links (%v); restart it with `make pdp` and run `make verify`", err) + } else { + logf("PDP has the returned instances' parent links") + } + } + if pdpUp { + ensureLinks(p, churn) + } + if !*reset || len(returned) > 0 { + rebuildRelationshipCache(p) + } + fmt.Println("Done. The PDP receives data changes within a few seconds; restart the app to reload its data.") +} + +// ensureLinks confirms the PDP has every seeded parent link, and keeps them. When instances or +// organizations were deleted and created again with the same keys (transfer, merge, delete, then a +// reset), the deletions of the old links can reach the PDP after the new links and remove them, and +// for a while the data Permit serves on a refetch can lack them too. So after any deletion the links +// must stay complete for 45 s in a row; whenever some are missing the PDP refetches its data. After +// five minutes it restarts the PDP as a last resort. +func ensureLinks(p *pdp.Client, churn bool) { + links := map[string]string{} + for _, t := range model.Tuples { + if t.Relation == "parent" { + links[t.Object] = t.Subject + } + } + stable := 5 * time.Second + if churn { + stable = 45 * time.Second + } + unreadable := func(err error) { + logf("couldn't read the PDP's data (%v); run `make verify` once it is reachable", err) + } + missing, err := p.Missing(links) + if err != nil { + unreadable(err) + return + } + if len(missing) > 0 { + logf("the PDP is missing %d parent links (%s); waiting for them, refetching its data if needed", len(missing), strings.Join(missing, ", ")) + } + refetches, err := p.Keep(links, stable, 5*time.Minute) + if err == nil { + note := "" + if refetches > 0 { + note = fmt.Sprintf(" after %d data refetches", refetches) + } + logf("PDP has all %d parent links%s", len(links), note) + return + } + if _, behind := errors.AsType[*pdp.NotCaughtUpError](err); !behind { + unreadable(err) + return + } + logf("the links still come and go after five minutes; restarting the PDP") + if err := p.Restart(); err != nil { + logf("%v", err) + return + } + if _, err := p.Keep(links, 10*time.Second, time.Minute); err != nil { + logf("%v; run `make pdp`, then `make verify`", err) + return + } + logf("PDP restarted; it has all %d parent links", len(links)) +} + +// rebuildRelationshipCache waits until the local PDP has loaded the schema, then rebuilds its +// relationship cache. Skipped when the PDP isn't running. +func rebuildRelationshipCache(p *pdp.Client) { + if !p.Status().PDP { + logf("local PDP not running: start it, then run `make setup` again to rebuild its relationship cache") + return + } + deadline := time.Now().Add(90 * time.Second) + for { + loaded, err := pdpHasSchema(p) + if err != nil { + logf("couldn't read the PDP's data (%v); run `make setup` again once it is reachable", err) + return + } + if loaded { + break + } + if time.Now().After(deadline) { + logf("the PDP has not loaded the whole schema after 90 s; run `make setup` again later") + return + } + time.Sleep(2 * time.Second) + } + if err := p.RebuildRelationshipCache(); err != nil { + logf("rebuilding the PDP's relationship cache failed: %v", err) + return + } + logf("PDP has the schema; relationship cache rebuilt") +} + +// pdpHasSchema reports whether the PDP's loaded data has every resource action, role permission and +// role derivation in the schema. +func pdpHasSchema(p *pdp.Client) (bool, error) { + typesData, err := p.OPAData("resource_types") + if err != nil { + return false, err + } + permsData, err := p.OPAData("role_permissions") + if err != nil { + return false, err + } + types, _ := typesData.(map[string]any) + perms, _ := permsData.(map[string]any) + for _, r := range schema.Resources { + rt, _ := types[r.Key].(map[string]any) + actions, _ := rt["actions"].([]any) + for _, a := range r.Actions { + if !slices.Contains(actions, any(a)) { + return false, nil + } + } + derived, _ := rt["derived_roles"].(map[string]any) + rolePerms, _ := perms[r.Key].(map[string]any) + for _, role := range r.Roles { + if len(role.DerivedFrom) > 0 { + d, _ := derived[role.Key].(map[string]any) + if rules, _ := d["rules"].([]any); len(rules) < len(role.DerivedFrom) { + return false, nil + } + } + rp, _ := rolePerms[role.Key].(map[string]any) + grants, _ := rp["grants"].(map[string]any) + have, _ := grants[r.Key].([]any) + for _, perm := range role.Permissions { + if !slices.Contains(have, any(perm)) { + return false, nil + } + } + } + } + return true, nil +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "sync failed:", err) + os.Exit(1) +} + +func logf(format string, args ...any) { fmt.Printf("• "+format+"\n", args...) } + +// title turns an action key into its display name: "service_basic" -> "Service basic". +func title(key string) string { + s := strings.ToLower(strings.ReplaceAll(key, "_", " ")) + return strings.ToUpper(s[:1]) + s[1:] +} + +func ptr[T any](v T) *T { return &v } + +// call runs one API call (with the timeout and retries); statuses in allow count as success. +func call(fn func(ctx context.Context) error, allow ...int) error { + return permitapi.Call(fn, allow...) +} + +// syncSchema makes the schema in Permit match package schema: first it removes what the schema +// doesn't have (rules and sets before the roles and types they name), then creates and updates the rest. +func syncSchema(a *api.PermitApiClient, raw rest, scope keyScope) error { + // User attributes, created once: Permit answers 500 to a PATCH of an existing one. Permit's own + // (key, email, roles) are listed among them, so nothing here is removed. + for _, attr := range schema.UserAttributes { + body := models.ResourceAttributeCreate{Key: attr.Key, Type: models.AttributeType(attr.Type), Description: ptr(attr.Description)} + if err := call(func(ctx context.Context) error { + _, err := a.ResourceAttributes.Create(ctx, "__user", body) + return err + }, 409); err != nil { + return fmt.Errorf("user attribute %s: %w", attr.Key, err) + } + } + if err := pruneSchema(a, raw, scope); err != nil { + return err + } + + for _, r := range schema.Resources { + actions := map[string]models.ActionBlockEditable{} + for _, key := range r.Actions { + actions[key] = models.ActionBlockEditable{Name: ptr(title(key))} + } + create := func(ctx context.Context) error { + _, err := a.Resources.Create(ctx, *models.NewResourceCreate(r.Key, r.Name, actions)) + return err + } + update := func(ctx context.Context) error { + _, err := a.Resources.Update(ctx, r.Key, models.ResourceUpdate{Name: ptr(r.Name), Actions: &actions}) + return err + } + if _, err := permitapi.Ensure(create, update); err != nil { + return fmt.Errorf("resource %s: %w", r.Key, err) + } + for _, attr := range r.Attributes { + body := models.ResourceAttributeCreate{Key: attr.Key, Type: models.AttributeType(attr.Type), Description: ptr(attr.Description)} + if err := call(func(ctx context.Context) error { _, err := a.ResourceAttributes.Create(ctx, r.Key, body); return err }, 409); err != nil { + return fmt.Errorf("attribute %s.%s: %w", r.Key, attr.Key, err) + } + } + } + // Relations after every resource type exists, since a relation names its parent type. + var keys []string + for _, r := range schema.Resources { + keys = append(keys, r.Key) + for _, rel := range r.Relations { + body := *models.NewRelationCreate(rel.Key, title(rel.Key), rel.Subject) + if err := call(func(ctx context.Context) error { _, err := a.ResourceRelations.Create(ctx, r.Key, body); return err }, 409); err != nil { + return fmt.Errorf("relation %s.%s: %w", r.Key, rel.Key, err) + } + } + } + logf("resources: %s", strings.Join(keys, ", ")) + + // Roles with exactly their permissions, then derivations, so every role a derivation names exists. + for _, r := range schema.Resources { + for _, role := range r.Roles { + create := func(ctx context.Context) error { + _, err := a.ResourceRoles.Create(ctx, r.Key, models.ResourceRoleCreate{Key: role.Key, Name: role.Name, Permissions: role.Permissions}) + return err + } + update := func(ctx context.Context) error { + _, err := a.ResourceRoles.Update(ctx, r.Key, role.Key, models.ResourceRoleUpdate{Name: ptr(role.Name), Permissions: role.Permissions}) + return err + } + if _, err := permitapi.Ensure(create, update); err != nil { + return fmt.Errorf("role %s#%s: %w", r.Key, role.Key, err) + } + } + } + derivations := 0 + for _, r := range schema.Resources { + for _, role := range r.Roles { + for _, d := range role.DerivedFrom { + body := *models.NewDerivedRoleRuleCreate(d.Role, d.OnResource, d.Relation) + if err := call(func(ctx context.Context) error { + _, err := a.ImplicitGrants.Create(ctx, r.Key, role.Key, body) + return err + }, 409); err != nil { + return fmt.Errorf("derivation %s#%s from %s#%s: %w", r.Key, role.Key, d.OnResource, d.Role, err) + } + derivations++ + } + } + } + logf("resource roles, with %d derivations", derivations) + + var tenantRoles []string + for _, tr := range schema.TenantRoles { + tenantRoles = append(tenantRoles, tr.Key) + create := func(ctx context.Context) error { + _, err := a.Roles.Create(ctx, models.RoleCreate{Key: tr.Key, Name: tr.Name, Description: ptr(tr.Description), Permissions: tr.Permissions}) + return err + } + update := func(ctx context.Context) error { + _, err := a.Roles.Update(ctx, tr.Key, models.RoleUpdate{Name: ptr(tr.Name), Description: ptr(tr.Description), Permissions: tr.Permissions}) + return err + } + if _, err := permitapi.Ensure(create, update); err != nil { + return fmt.Errorf("role %s: %w", tr.Key, err) + } + } + logf("org-level (tenant) roles: %s", strings.Join(tenantRoles, ", ")) + + var setKeys, rules []string + for _, cs := range schema.ConditionSets { + setKeys = append(setKeys, cs.Key) + body := models.ConditionSetCreate{Key: cs.Key, Name: cs.Name, Type: ptr(models.ConditionSetType(cs.Type)), Conditions: cs.Conditions()} + if cs.Resource != "" { + body.ResourceId = &models.ResourceId{String: ptr(cs.Resource)} + } + update := func(ctx context.Context) error { + _, err := a.ConditionSets.Update(ctx, cs.Key, models.ConditionSetUpdate{Name: ptr(cs.Name), Conditions: cs.Conditions()}) + return err + } + if _, err := permitapi.Ensure(func(ctx context.Context) error { _, err := a.ConditionSets.Create(ctx, body); return err }, update); err != nil { + return fmt.Errorf("condition set %s: %w", cs.Key, err) + } + } + for _, rule := range schema.ConditionSetRules { + rules = append(rules, fmt.Sprintf("%s %s on %s", strings.TrimPrefix(rule.UserSet, "__autogen_"), rule.Permission, rule.ResourceSet)) + if err := call(func(ctx context.Context) error { + _, err := a.ConditionSets.AssignSetPermissions(ctx, rule.UserSet, rule.Permission, rule.ResourceSet) + return err + }, 409); err != nil { + return fmt.Errorf("rule %s %s on %s: %w", rule.UserSet, rule.Permission, rule.ResourceSet, err) + } + } + logf("ABAC sets: %s; rules: %s", strings.Join(setKeys, ", "), strings.Join(rules, "; ")) + return nil +} + +// pruneSchema removes what package schema doesn't have: attribute rules, condition sets and org-level +// roles first, then resource types, then the derivations, roles, actions, attributes and relations of +// the remaining types. +func pruneSchema(a *api.PermitApiClient, raw rest, scope keyScope) error { + var removed []string + + have, err := listRaw[rule](raw, scope, "facts", "set_rules") + if err != nil { + return err + } + for _, r := range have { + if slices.Contains(schema.ConditionSetRules, schema.ConditionSetRule{UserSet: r.UserSet, Permission: r.Permission, ResourceSet: r.ResourceSet}) { + continue + } + if err := call(func(ctx context.Context) error { + return a.ConditionSets.UnassignSetPermissions(ctx, r.UserSet, r.Permission, r.ResourceSet) + }, 404); err != nil { + return fmt.Errorf("removing rule %s %s on %s: %w", r.UserSet, r.Permission, r.ResourceSet, err) + } + removed = append(removed, fmt.Sprintf("rule %s %s on %s", r.UserSet, r.Permission, r.ResourceSet)) + } + + sets, err := listRaw[conditionSet](raw, scope, "schema", "condition_sets") + if err != nil { + return err + } + for _, cs := range sets { + if schema.IsRoleSet(cs.Key) || cs.Autogenerated { + continue + } + if _, ok := schema.ConditionSetByKey(cs.Key); ok { + continue + } + if err := call(func(ctx context.Context) error { return a.ConditionSets.Delete(ctx, cs.Key) }, 404); err != nil { + return fmt.Errorf("removing condition set %s: %w", cs.Key, err) + } + removed = append(removed, "condition set "+cs.Key) + } + + roles, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RoleRead, error) { + return a.Roles.List(ctx, page, perPage) + }) + if err != nil { + return fmt.Errorf("listing org-level roles: %w", err) + } + for _, role := range roles { + if slices.ContainsFunc(schema.TenantRoles, func(tr schema.TenantRole) bool { return tr.Key == role.Key }) { + continue + } + if err := call(func(ctx context.Context) error { return a.Roles.Delete(ctx, role.Key) }, 404); err != nil { + return fmt.Errorf("removing org-level role %s: %w", role.Key, err) + } + removed = append(removed, "org-level role "+role.Key) + } + + resources, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.ResourceRead, error) { + return a.Resources.List(ctx, page, perPage) + }) + if err != nil { + return fmt.Errorf("listing resource types: %w", err) + } + for _, res := range resources { + if _, ok := schema.ResourceByKey(res.Key); ok || strings.HasPrefix(res.Key, "__") { + continue + } + if err := call(func(ctx context.Context) error { return a.Resources.Delete(ctx, res.Key) }, 404); err != nil { + return fmt.Errorf("removing resource type %s: %w", res.Key, err) + } + removed = append(removed, "resource type "+res.Key) + } + + for _, res := range resources { + want, ok := schema.ResourceByKey(res.Key) + if !ok { + continue + } + current, err := permitapi.Deref(func() (*[]models.ResourceRoleRead, error) { + var out *[]models.ResourceRoleRead + err := call(func(ctx context.Context) error { + var err error + out, err = a.ResourceRoles.List(ctx, 1, 100, res.Key) + return err + }) + return out, err + }()) + if err != nil { + return fmt.Errorf("listing the roles of %s: %w", res.Key, err) + } + // Derivations the schema doesn't have, on the roles that stay; then the roles that go. + for _, role := range current { + wantRole := slices.IndexFunc(want.Roles, func(x schema.Role) bool { return x.Key == role.Key }) + if wantRole < 0 || role.GrantedTo == nil { + continue + } + for _, rule := range role.GrantedTo.UsersWithRole { + d := schema.Derivation{Role: rule.Role, OnResource: rule.OnResource, Relation: rule.LinkedByRelation} + if slices.Contains(want.Roles[wantRole].DerivedFrom, d) { + continue + } + // The SDK's Delete names its arguments (roleId, resourceId) but builds the URL from them as + // (resource, role), the same order as Create. + body := *models.NewDerivedRoleRuleDelete(rule.Role, rule.OnResource, rule.LinkedByRelation) + if err := call(func(ctx context.Context) error { return a.ImplicitGrants.Delete(ctx, res.Key, role.Key, body) }, 404); err != nil { + return fmt.Errorf("derivation %s#%s from %s#%s: %w", res.Key, role.Key, rule.OnResource, rule.Role, err) + } + removed = append(removed, fmt.Sprintf("derivation %s#%s from %s#%s", res.Key, role.Key, rule.OnResource, rule.Role)) + } + } + for _, role := range current { + if slices.ContainsFunc(want.Roles, func(x schema.Role) bool { return x.Key == role.Key }) { + continue + } + if err := call(func(ctx context.Context) error { return a.ResourceRoles.Delete(ctx, res.Key, role.Key) }, 404); err != nil { + return fmt.Errorf("removing role %s#%s: %w", res.Key, role.Key, err) + } + removed = append(removed, fmt.Sprintf("role %s#%s", res.Key, role.Key)) + } + if res.Actions != nil { + for key := range *res.Actions { + if slices.Contains(want.Actions, key) { + continue + } + if err := call(func(ctx context.Context) error { return a.ResourceActions.Delete(ctx, res.Key, key) }, 404); err != nil { + return fmt.Errorf("removing action %s:%s: %w", res.Key, key, err) + } + removed = append(removed, fmt.Sprintf("action %s:%s", res.Key, key)) + } + } + if res.Attributes != nil { + for key := range *res.Attributes { + if slices.ContainsFunc(want.Attributes, func(x schema.Attribute) bool { return x.Key == key }) { + continue + } + if err := call(func(ctx context.Context) error { return a.ResourceAttributes.Delete(ctx, res.Key, key) }, 404); err != nil { + return fmt.Errorf("removing attribute %s.%s: %w", res.Key, key, err) + } + removed = append(removed, fmt.Sprintf("attribute %s.%s", res.Key, key)) + } + } + relations, err := permitapi.Deref(func() (*[]models.RelationRead, error) { + var out *[]models.RelationRead + err := call(func(ctx context.Context) error { + var err error + out, err = a.ResourceRelations.List(ctx, 1, 100, res.Key) + return err + }) + return out, err + }()) + if err != nil { + return fmt.Errorf("listing the relations of %s: %w", res.Key, err) + } + for _, rel := range relations { + if slices.ContainsFunc(want.Relations, func(x schema.Relation) bool { return x.Key == rel.Key }) { + continue + } + if err := call(func(ctx context.Context) error { return a.ResourceRelations.Delete(ctx, res.Key, rel.Key) }, 404); err != nil { + return fmt.Errorf("removing relation %s.%s: %w", res.Key, rel.Key, err) + } + removed = append(removed, fmt.Sprintf("relation %s.%s", res.Key, rel.Key)) + } + } + if len(removed) > 0 { + logf("removed from Permit (not in the schema): %s", strings.Join(removed, "; ")) + } + return nil +} + +type rule struct { + UserSet string `json:"user_set"` + Permission string `json:"permission"` + ResourceSet string `json:"resource_set"` +} + +type conditionSet struct { + Key string `json:"key"` + Autogenerated bool `json:"autogenerated"` +} + +// rest makes GET calls to Permit's REST API with the key: for the key's scope, and for the lists +// the SDK can't read (see listRaw). +type rest struct{ url, key string } + +// get reads path (with its query) into into. A non-200 answer is a *permitapi.HTTPError, so call's +// retries and allowed statuses see its status. +func (r rest) get(ctx context.Context, path string, into any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(r.url, "/")+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+r.key) + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return &permitapi.HTTPError{Method: http.MethodGet, Path: req.URL.Path, StatusCode: res.StatusCode, Status: res.Status} + } + if err := json.NewDecoder(res.Body).Decode(into); err != nil { + return fmt.Errorf("decoding the answer to GET %s: %w", req.URL.Path, err) + } + return nil +} + +// keyScope is the environment PERMIT_API_KEY belongs to, as Permit reports it. +type keyScope struct { + ProjectID, ProjectKey, EnvID, EnvKey, EnvName string +} + +// scopeOf asks Permit which project and environment the key belongs to. +func scopeOf(api rest) (keyScope, error) { + var ids struct { + ProjectID string `json:"project_id"` + EnvironmentID string `json:"environment_id"` + } + if err := call(func(ctx context.Context) error { return api.get(ctx, "/v2/api-key/scope", &ids) }); err != nil { + return keyScope{}, fmt.Errorf("reading the scope of PERMIT_API_KEY: %w", err) + } + if ids.ProjectID == "" || ids.EnvironmentID == "" { + return keyScope{}, errors.New("PERMIT_API_KEY is not an environment's key: use the API key of the environment the demo runs in") + } + type named struct { + Key string `json:"key"` + Name string `json:"name"` + } + var project, env named + if err := call(func(ctx context.Context) error { return api.get(ctx, "/v2/projects/"+ids.ProjectID, &project) }); err != nil { + return keyScope{}, fmt.Errorf("reading project %s: %w", ids.ProjectID, err) + } + if err := call(func(ctx context.Context) error { + return api.get(ctx, "/v2/projects/"+ids.ProjectID+"/envs/"+ids.EnvironmentID, &env) + }); err != nil { + return keyScope{}, fmt.Errorf("reading environment %s: %w", ids.EnvironmentID, err) + } + return keyScope{ProjectID: ids.ProjectID, ProjectKey: project.Key, EnvID: ids.EnvironmentID, EnvKey: env.Key, EnvName: env.Name}, nil +} + +// check refuses to go on when .env names another project or environment than the key's (by key or +// ID); unset names aren't compared. +func (k keyScope) check(project, env string) error { + if project != "" && project != k.ProjectKey && project != k.ProjectID { + return fmt.Errorf("PERMIT_PROJECT in .env is %q, but PERMIT_API_KEY belongs to project %q: nothing was changed; fix .env first", project, k.ProjectKey) + } + if env != "" && env != k.EnvKey && env != k.EnvID { + return fmt.Errorf("PERMIT_ENV in .env is %q, but PERMIT_API_KEY belongs to environment %q (%s): nothing was changed; fix .env first", env, k.EnvKey, k.EnvName) + } + return nil +} + +// listRaw lists every item of an environment-level API collection, e.g. ("facts", "set_rules"), in +// the key's own environment. Two SDK list methods can't do it: the rules list always sends its three +// filters, and empty filters match nothing; the condition sets list expects a paginated answer and +// reads the API's plain list as empty. +func listRaw[T any](api rest, scope keyScope, area, collection string) ([]T, error) { + var all []T + for page := 1; ; page++ { + var batch []T + path := fmt.Sprintf("/v2/%s/%s/%s/%s?page=%d&per_page=100", area, scope.ProjectID, scope.EnvID, collection, page) + if err := call(func(ctx context.Context) error { return api.get(ctx, path, &batch) }); err != nil { + return nil, fmt.Errorf("listing %s: %w", collection, err) + } + all = append(all, batch...) + if len(batch) < 100 { + return all, nil + } + } +} + +// syncFacts writes tenants, users, resource instances, parent links and role assignments, and removes +// whatever the seed doesn't have: organizations, people, instances, links and role assignments added +// in the app or left from an earlier version. Moved instances go back to their organization and stored +// attributes are restored. +// returned collects the instances deleted and created again in their seeded organization; churn +// reports whether anything was deleted. +func syncFacts(a *api.PermitApiClient, returned *[]string, waitGone, waitUnlinked func(ids []string)) (churn bool, err error) { + defer func() { churn = churn || len(*returned) > 0 }() + tenants, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.TenantRead, error) { + return a.Tenants.List(ctx, page, perPage) + }) + if err != nil { + return false, fmt.Errorf("listing tenants: %w", err) + } + var gone []string + for _, t := range tenants { + if _, ok := model.TenantOf(t.Key); ok || t.Key == "default" { + continue + } + if err := call(func(ctx context.Context) error { return a.Tenants.Delete(ctx, t.Key) }, 404); err != nil { + return false, fmt.Errorf("removing tenant %s: %w", t.Key, err) + } + gone = append(gone, t.Key) + churn = true + } + for _, t := range model.Tenants { + create := func(ctx context.Context) error { + _, err := a.Tenants.Create(ctx, models.TenantCreate{Key: t.Key, Name: t.Name, Description: ptr(t.Description)}) + return err + } + update := func(ctx context.Context) error { + _, err := a.Tenants.Update(ctx, t.Key, models.TenantUpdate{Name: ptr(t.Name), Description: ptr(t.Description)}) + return err + } + if _, err := permitapi.Ensure(create, update); err != nil { + return false, fmt.Errorf("tenant %s: %w", t.Key, err) + } + } + users, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.UserRead, error) { + return a.Users.List(ctx, page, perPage) + }) + if err != nil { + return false, fmt.Errorf("listing users: %w", err) + } + for _, u := range users { + if _, ok := model.UserOf(u.Key); ok { + continue + } + if err := call(func(ctx context.Context) error { return a.Users.Delete(ctx, u.Key) }, 404); err != nil { + return false, fmt.Errorf("removing user %s: %w", u.Key, err) + } + gone = append(gone, "user "+u.Key) + churn = true + } + for _, u := range model.Users { + body := models.UserCreate{ + Key: u.Key, Email: ptr(u.Email), FirstName: ptr(u.FirstName), LastName: ptr(u.LastName), + Attributes: map[string]any{"home_tenant": u.HomeTenant, "certifications": u.Certifications}, + } + if err := call(func(ctx context.Context) error { _, err := a.Users.SyncUser(ctx, body); return err }); err != nil { + return false, fmt.Errorf("user %s: %w", u.Key, err) + } + } + note := "" + if len(gone) > 0 { + note = fmt.Sprintf(" (removed %s)", strings.Join(gone, ", ")) + } + logf("tenants: %d, users: %d%s", len(model.Tenants), len(model.Users), note) + + rows, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.ResourceInstanceRead, error) { + return permitapi.Deref(a.ResourceInstances.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return false, fmt.Errorf("listing resource instances: %w", err) + } + have := map[string]models.ResourceInstanceRead{} + for _, i := range rows { + have[i.Resource+":"+i.Key] = i + } + updated := 0 + var moveBack []model.Instance // moved to another organization in the app (transfer, merge) + for _, i := range model.Instances { + current, exists := have[i.ID()] + switch { + case !exists: + body := models.ResourceInstanceCreate{Key: i.Key, Resource: i.Type, Tenant: ptr(i.Tenant), Attributes: i.Attributes} + if err := call(func(ctx context.Context) error { _, err := a.ResourceInstances.Create(ctx, body); return err }); err != nil { + return false, fmt.Errorf("instance %s: %w", i.ID(), err) + } + case deref(current.Tenant) != i.Tenant: + moveBack = append(moveBack, i) + case len(i.Attributes) > 0 && !reflect.DeepEqual(current.Attributes, i.Attributes): + body := models.ResourceInstanceUpdate{Attributes: i.Attributes} + if err := call(func(ctx context.Context) error { _, err := a.ResourceInstances.Update(ctx, i.ID(), body); return err }); err != nil { + return false, fmt.Errorf("instance %s: %w", i.ID(), err) + } + updated++ + } + } + // An instance can't change tenant: delete the moved ones, wait until the PDP has dropped them (so a + // late deletion can't remove the new ones), and create them again where the seed has them. The + // relationship and role-assignment steps below restore their links and grants. + if len(moveBack) > 0 { + var ids []string + for _, i := range moveBack { + ids = append(ids, i.ID()) + } + // Their links first, so the instance deletions have none left to remove late (see ensureLinks). + if err := unlink(a, ids, waitUnlinked); err != nil { + return false, err + } + ids = ids[:0] + for _, i := range moveBack { + if err := call(func(ctx context.Context) error { return a.ResourceInstances.Delete(ctx, i.ID()) }, 404); err != nil { + return false, fmt.Errorf("instance %s: %w", i.ID(), err) + } + ids = append(ids, i.ID()) + } + waitGone(ids) + for _, i := range moveBack { + body := models.ResourceInstanceCreate{Key: i.Key, Resource: i.Type, Tenant: ptr(i.Tenant), Attributes: i.Attributes} + if err := permitapi.CreateAgain(func(ctx context.Context) error { _, err := a.ResourceInstances.Create(ctx, body); return err }); err != nil { + return false, fmt.Errorf("instance %s: %w", i.ID(), err) + } + *returned = append(*returned, i.ID()) + } + } + var extra []string + for id := range have { + if _, seeded := model.SeedByID[id]; !seeded { + extra = append(extra, id) + } + } + for _, id := range extra { + if err := call(func(ctx context.Context) error { return a.ResourceInstances.Delete(ctx, id) }, 404); err != nil { + return false, fmt.Errorf("instance %s: %w", id, err) + } + } + churn = churn || len(extra) > 0 + note = "" + if len(extra) > 0 { + note += fmt.Sprintf(", removed %d not in the seed", len(extra)) + } + if updated > 0 { + note += fmt.Sprintf(", restored attributes on %d", updated) + } + if len(*returned) > 0 { + note += fmt.Sprintf(", returned %d to their organization", len(*returned)) + } + if note != "" { + note = " (" + strings.TrimPrefix(note, ", ") + ")" + } + logf("resource instances: %d%s", len(model.Instances), note) + + tupleID := func(subject, relation, object string) string { return subject + "|" + relation + "|" + object } + tuples, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RelationshipTupleRead, error) { + return permitapi.Deref(a.RelationshipTuples.List(ctx, page, perPage, "", "", "", "")) + }) + if err != nil { + return false, fmt.Errorf("listing relationships: %w", err) + } + haveT, wantT := map[string]bool{}, map[string]bool{} + for _, t := range tuples { + haveT[tupleID(t.Subject, t.Relation, t.Object)] = true + } + var create []models.RelationshipTupleCreate + for _, t := range model.Tuples { + id := tupleID(t.Subject, t.Relation, t.Object) + wantT[id] = true + if !haveT[id] { + create = append(create, models.RelationshipTupleCreate{Subject: t.Subject, Relation: t.Relation, Object: t.Object, Tenant: ptr(t.Tenant)}) + } + } + for n := 0; n < len(create); n += 100 { + batch := create[n:min(n+100, len(create))] + if err := call(func(ctx context.Context) error { + return a.RelationshipTuples.BulkCreate(ctx, *models.NewRelationshipTupleCreateBulkOperation(batch)) + }); err != nil { + return false, fmt.Errorf("creating %d relationships: %w", len(batch), err) + } + } + var remove []models.RelationshipTupleDelete + for _, t := range tuples { + if !wantT[tupleID(t.Subject, t.Relation, t.Object)] { + remove = append(remove, *models.NewRelationshipTupleDelete(t.Subject, t.Relation, t.Object)) + } + } + if len(remove) > 0 { + if err := call(func(ctx context.Context) error { + return a.RelationshipTuples.BulkDelete(ctx, *models.NewRelationshipTupleDeleteBulkOperation(remove)) + }); err != nil { + return false, fmt.Errorf("removing %d relationships: %w", len(remove), err) + } + } + churn = churn || len(remove) > 0 + logf("relationships (parent links): %d (%d created, %d removed)", len(model.Tuples), len(create), len(remove)) + + raID := func(user, role, instance, tenant string) string { + return user + "|" + role + "|" + instance + "|" + tenant + } + current, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RoleAssignmentRead, error) { + return permitapi.Deref(a.RoleAssignments.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return false, fmt.Errorf("listing role assignments: %w", err) + } + haveRA, wantRA := map[string]bool{}, map[string]bool{} + for _, r := range current { + haveRA[raID(r.User, r.Role, deref(r.ResourceInstance), r.Tenant)] = true + } + var assign []models.RoleAssignmentCreate + for _, r := range model.RoleAssignments { + id := raID(r.User, r.Role, r.ResourceInstance, r.Tenant) + wantRA[id] = true + if !haveRA[id] { + body := *models.NewRoleAssignmentCreate(r.Role, r.Tenant, r.User) + if r.ResourceInstance != "" { + body.ResourceInstance = ptr(r.ResourceInstance) + } + assign = append(assign, body) + } + } + if len(assign) > 0 { + if err := call(func(ctx context.Context) error { _, err := a.Roles.BulkAssignRole(ctx, assign); return err }); err != nil { + return false, fmt.Errorf("assigning %d roles: %w", len(assign), err) + } + } + var unassign []models.RoleAssignmentRemove + for _, r := range current { + if !wantRA[raID(r.User, r.Role, deref(r.ResourceInstance), r.Tenant)] { + body := *models.NewRoleAssignmentRemove(r.Role, r.Tenant, r.User) + body.ResourceInstance = r.ResourceInstance + unassign = append(unassign, body) + } + } + if len(unassign) > 0 { + if err := call(func(ctx context.Context) error { _, err := a.Roles.BulkUnAssignRole(ctx, unassign); return err }); err != nil { + return false, fmt.Errorf("removing %d role assignments: %w", len(unassign), err) + } + } + logf("role assignments: %d (%d created, %d removed)", len(model.RoleAssignments), len(assign), len(unassign)) + return churn, nil +} + +// unlink deletes the relationship tuples that name any of the instances, then waits for the PDP to drop them. +func unlink(a *api.PermitApiClient, ids []string, waitUnlinked func(ids []string)) error { + tuples, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RelationshipTupleRead, error) { + return permitapi.Deref(a.RelationshipTuples.List(ctx, page, perPage, "", "", "", "")) + }) + if err != nil { + return fmt.Errorf("listing relationships: %w", err) + } + var del []models.RelationshipTupleDelete + for _, t := range tuples { + if slices.Contains(ids, t.Subject) || slices.Contains(ids, t.Object) { + del = append(del, *models.NewRelationshipTupleDelete(t.Subject, t.Relation, t.Object)) + } + } + if len(del) == 0 { + return nil + } + if err := call(func(ctx context.Context) error { + return a.RelationshipTuples.BulkDelete(ctx, *models.NewRelationshipTupleDeleteBulkOperation(del)) + }); err != nil { + return fmt.Errorf("removing the links of %s: %w", strings.Join(ids, ", "), err) + } + waitUnlinked(ids) + return nil +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/fleet-ops-demo/cmd/sync/main_test.go b/fleet-ops-demo/cmd/sync/main_test.go new file mode 100644 index 0000000..7429c67 --- /dev/null +++ b/fleet-ops-demo/cmd/sync/main_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "fleetops/internal/permitapi" +) + +// fakeAPI answers like Permit's API for an environment key of project "fleet" (id p-1), +// environment "demo" (id e-1). +func fakeAPI(t *testing.T, scope string, handle func(w http.ResponseWriter, r *http.Request) bool) rest { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer permit_key_test" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if handle != nil && handle(w, r) { + return + } + switch r.URL.Path { + case "/v2/api-key/scope": + _, _ = w.Write([]byte(scope)) + case "/v2/projects/p-1": + _, _ = w.Write([]byte(`{"id":"p-1","key":"fleet","name":"Fleet"}`)) + case "/v2/projects/p-1/envs/e-1": + _, _ = w.Write([]byte(`{"id":"e-1","key":"demo","name":"Demo"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return rest{url: srv.URL, key: "permit_key_test"} +} + +const envScope = `{"organization_id":"o-1","project_id":"p-1","environment_id":"e-1"}` + +func TestScopeOf(t *testing.T) { + scope, err := scopeOf(fakeAPI(t, envScope, nil)) + if err != nil { + t.Fatal(err) + } + want := keyScope{ProjectID: "p-1", ProjectKey: "fleet", EnvID: "e-1", EnvKey: "demo", EnvName: "Demo"} + if scope != want { + t.Fatalf("got %+v, want %+v", scope, want) + } + if _, err := scopeOf(fakeAPI(t, `{"organization_id":"o-1","project_id":"p-1"}`, nil)); err == nil { + t.Fatal("a project key passed as an environment's key") + } +} + +// setup and reset delete what the seed doesn't have: they stop when .env names another project or +// environment than the key's. +func TestScopeCheck(t *testing.T) { + scope := keyScope{ProjectID: "p-1", ProjectKey: "fleet", EnvID: "e-1", EnvKey: "demo", EnvName: "Demo"} + for name, tc := range map[string]struct { + project, env string + ok bool + }{ + "keys match": {"fleet", "demo", true}, + "IDs match": {"p-1", "e-1", true}, + "not set": {"", "", true}, + "another environment": {"fleet", "production", false}, + "another project": {"customer", "demo", false}, + "only the env, another": {"", "production", false}, + } { + t.Run(name, func(t *testing.T) { + if err := scope.check(tc.project, tc.env); (err == nil) != tc.ok { + t.Fatalf("got %v, want ok=%v", err, tc.ok) + } + }) + } +} + +// listRaw reads the key's own environment, every page, and its errors carry the status, so +// call's retries and allowed statuses apply to them. +func TestListRaw(t *testing.T) { + scope := keyScope{ProjectID: "p-1", EnvID: "e-1"} + t.Run("every page, in the key's environment", func(t *testing.T) { + api := fakeAPI(t, envScope, func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/v2/facts/p-1/e-1/set_rules" { + return false + } + n := 100 + if r.URL.Query().Get("page") == "2" { + n = 3 + } + rules := make([]rule, n) + for i := range rules { + rules[i] = rule{UserSet: fmt.Sprintf("u%d", i)} + } + _ = json.NewEncoder(w).Encode(rules) + return true + }) + got, err := listRaw[rule](api, scope, "facts", "set_rules") + if err != nil || len(got) != 103 { + t.Fatalf("got %d rules, %v; want 103", len(got), err) + } + }) + t.Run("a failure carries its status", func(t *testing.T) { + api := fakeAPI(t, envScope, nil) + _, err := listRaw[rule](api, scope, "facts", "set_rules") + if permitapi.Status(err) != http.StatusNotFound || !strings.Contains(err.Error(), "listing set_rules") { + t.Fatalf("got %v, want a 404 that says what was listed", err) + } + }) +} diff --git a/fleet-ops-demo/cmd/verify/main.go b/fleet-ops-demo/cmd/verify/main.go new file mode 100644 index 0000000..88c3b95 --- /dev/null +++ b/fleet-ops-demo/cmd/verify/main.go @@ -0,0 +1,97 @@ +// Command verify is the pre-flight before the call: is the PDP up, are the custom rules loaded, and +// does the PDP still give every decision the scenarios rely on? Exits non-zero if anything is off. +// +// make verify +package main + +import ( + "fmt" + "os" + "time" + + "fleetops/internal/checks" + "fleetops/internal/pdp" + "fleetops/internal/settings" +) + +func main() { + os.Exit(run()) +} + +func run() int { + s := settings.MustLoad() + client := pdp.New(s, settings.Client(s)) + + st := client.Status() + rules := yesNo(st.ActingOrgRule) + if st.RulesUnknown != "" { + rules = "unknown" + } + fmt.Printf("PDP reachable ......................................... %s\n", yesNo(st.PDP)) + fmt.Printf("Custom rules loaded (tenant isolation, fleet rules) .... %s\n", rules) + if !st.PDP { + fmt.Fprintln(os.Stderr, "\nStart the PDP with `make pdp` and wait for http://localhost:7766/healthy.") + return 1 + } + switch { + case st.RulesUnknown != "": + fmt.Fprintf(os.Stderr, "\nCouldn't read the PDP's policy, so it is unknown whether the custom rules are loaded: %s\nCheck OPA_URL in .env: the PDP's OPA, port 8181 in docker-compose.yml.\n", st.RulesUnknown) + case !st.ActingOrgRule: + fmt.Fprintln(os.Stderr, "\nThe custom rules are missing from the PDP. Run `make apply-policy`.") + } + + requests := make([]pdp.Check, len(checks.Checks)) + for n, c := range checks.Checks { + requests[n] = pdp.NewCheck(c.User, c.Action, c.Resource, c.ActingOrg) + } + started := time.Now() + results, err := client.BulkCheck(requests) + if err != nil { + fmt.Fprintln(os.Stderr, "\npermitClient.BulkCheck failed:", err) + return 1 + } + fmt.Printf("\n%d checks through permitClient.BulkCheck in %d ms\n", len(requests), time.Since(started).Milliseconds()) + // Scenario 17: every decision names the version of the custom rules that decided it. + version := "missing" + debug, err := client.Debug(requests[0]) + if err != nil { + version = "unknown, the debug output couldn't be read: " + err.Error() + } + if fleet, ok := debug["fleet_rules"].(map[string]any); ok { + if v, ok := fleet["policy_version"].(string); ok { + version = v + } + } + fmt.Printf("Custom policy version in each decision ................ %s\n\n", version) + + failed := 0 + for n, c := range checks.Checks { + actual := "deny" + if results[n] { + actual = "allow" + } + status := "ok " + if actual != c.Expect { + status = "FAIL" + failed++ + } + fmt.Printf("%s %-6s %-7s %-17s %-24s acting for %-9s expect %-5s got %s\n", status, c.ID, c.User, c.Action, c.Resource, c.ActingOrg, c.Expect, actual) + } + if failed > 0 { + fmt.Printf("\n%d check(s) did not match. Run `make reset`, then check again.\n", failed) + return 1 + } + if !st.ActingOrgRule { + fmt.Println("\nThe checks match, but the custom rules aren't confirmed loaded (see above).") + return 1 + } + fmt.Println("\nAll checks match.") + return 0 +} + +func yesNo(ok bool) string { + if ok { + return "yes" + } + return "NO" +} diff --git a/fleet-ops-demo/docker-compose.yml b/fleet-ops-demo/docker-compose.yml new file mode 100644 index 0000000..04b0bdd --- /dev/null +++ b/fleet-ops-demo/docker-compose.yml @@ -0,0 +1,13 @@ +# The local container PDP the app checks against. Reads PERMIT_API_KEY from .env. +# Attribute rules (ABAC) and custom Rego need a container PDP: the cloud PDP doesn't evaluate them. +services: + pdp: + image: permitio/pdp-v2:0.9.16 + container_name: fleet-ops-pdp + ports: + - "127.0.0.1:7766:7000" # PDP API: /allowed, /allowed/bulk, AuthZEN /access/v1/* + - "127.0.0.1:8181:8181" # embedded OPA, for reading the loaded policy and data + environment: + PDP_API_KEY: ${PERMIT_API_KEY} + PDP_DEBUG: "true" # each decision returns its reason in the "debug" field + restart: unless-stopped diff --git a/fleet-ops-demo/go.mod b/fleet-ops-demo/go.mod new file mode 100644 index 0000000..4bf97a7 --- /dev/null +++ b/fleet-ops-demo/go.mod @@ -0,0 +1,20 @@ +module fleetops + +go 1.26.0 + +toolchain go1.26.9 + +require ( + github.com/permitio/permit-golang v1.2.8 + go.uber.org/zap v1.26.0 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/fleet-ops-demo/go.sum b/fleet-ops-demo/go.sum new file mode 100644 index 0000000..79c9f63 --- /dev/null +++ b/fleet-ops-demo/go.sum @@ -0,0 +1,60 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/permitio/permit-golang v1.2.8 h1:sfApf4qUUbznSYuyCHzrzk/MUj1/QneKHCflZCzsdf0= +github.com/permitio/permit-golang v1.2.8/go.mod h1:U3ytJkUh6mH7dPiBt7cWbVVsRSxAiJtnuL7FFhbDk8s= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/fleet-ops-demo/internal/checks/checks.go b/fleet-ops-demo/internal/checks/checks.go new file mode 100644 index 0000000..65ee465 --- /dev/null +++ b/fleet-ops-demo/internal/checks/checks.go @@ -0,0 +1,118 @@ +// Package checks lists the pre-flight checks for `make verify`: the PDP decisions the runbook relies +// on, one group per scenario. +// +// Scenarios 4 (re-parenting), 6's transfer, 14 (time-boxed access), 15 (revocation) and 16 (tenant +// merge) are flows: the app runs them and its log shows the decisions before and after. +package checks + +import ( + "fmt" + + "fleetops/internal/model" +) + +type Check struct { + ID, User, ActingOrg, Resource, Action string + Expect string // allow or deny +} + +type row struct{ user, org, resource, action, expect string } + +var byScenario = []struct { + scenario string + rows []row +}{ + {"S1", []row{ // role derivation down the tree + {"rachel", "acme", "asset:acme-hou-01", "view", "allow"}, // region manager: every site and truck in South + {"rachel", "acme", "asset:acme-dal-02", "view", "allow"}, + {"sam", "acme", "asset:acme-dal-01", "view", "allow"}, // site manager: Dallas only + {"sam", "acme", "asset:acme-hou-01", "view", "deny"}, // Houston is a sibling site + {"sam", "acme", "site:acme-houston", "view", "deny"}, + }}, + {"S2", []row{ // scoped admin delegation + {"rachel", "acme", "site:acme-houston", "assign_roles", "allow"}, + {"sam", "acme", "site:acme-dallas", "assign_roles", "allow"}, + {"sam", "acme", "site:acme-houston", "assign_roles", "deny"}, + }}, + {"S3", []row{ // nested orgs + {"avery", "summit", "asset:summit-atl-01", "view", "allow"}, // parent org's admin, into Summit East + {"avery", "summit", "asset:summit-den-01", "view", "allow"}, // and into Summit West + {"jamie", "summit", "asset:summit-atl-01", "view", "allow"}, // Summit East's admin + {"jamie", "summit", "asset:summit-den-01", "view", "deny"}, // nothing in Summit West + }}, + {"S5", []row{ // team supervisor + {"sam", "acme", "team:acme-dallas-night", "view_activity", "allow"}, // supervises the night shift + {"sam", "acme", "team:acme-dallas-day", "view_activity", "deny"}, // not the day shift + {"ivy", "acme", "team:acme-dallas-night", "view_activity", "deny"}, // members don't supervise + }}, + {"S6", []row{ // partner access on named trucks + {"pat", "acme", "asset:acme-dal-01", "service_basic", "allow"}, + {"pat", "acme", "asset:acme-hou-01", "view", "deny"}, // not granted + {"pat", "mesa", "asset:acme-dal-01", "service_basic", "deny"}, // acting for his own organization + }}, + {"S7", []row{ // owner vs operator + {"lena", "lonestar", "asset:acme-dal-04", "operate", "allow"}, // the operating organization operates + {"lena", "lonestar", "asset:acme-dal-04", "decommission", "deny"}, // but owns nothing + {"sam", "acme", "asset:acme-dal-04", "decommission", "allow"}, // the owner keeps ownership actions + {"quinn", "acme", "asset:acme-dal-04", "operate", "deny"}, // but not operational ones + {"ivy", "acme", "asset:acme-dal-04", "service_basic", "deny"}, + // Acting for the operator, only the operator's own roles count: not a role the owner granted. + {"ivy", "lonestar", "asset:acme-dal-04", "service_basic", "deny"}, + {"jordan", "lonestar", "asset:acme-dal-04", "service_advanced", "deny"}, + }}, + {"S8", []row{ // broker, read-only on shared trucks + {"skyler", "acme", "asset:acme-hou-01", "view", "allow"}, + {"skyler", "lonestar", "asset:lonestar-ftw-01", "view", "allow"}, + {"skyler", "summit", "asset:summit-atl-01", "view", "allow"}, + {"skyler", "acme", "asset:acme-dal-01", "view", "deny"}, // not shared + {"skyler", "acme", "asset:acme-hou-01", "operate", "deny"}, // read-only + }}, + {"S9", []row{ // certifications + {"ivy", "acme", "asset:acme-dal-01", "service_basic", "allow"}, // tier 1 + {"ivy", "acme", "asset:acme-dal-01", "service_advanced", "deny"}, // no tier 3 + {"olivia", "acme", "org:acme", "manage_users", "allow"}, // the tenant admin gives tier 1 and 2 + {"olivia", model.PlatformTenant, model.PlatformOrg, "manage_users", "deny"}, // but not tier 3 + {"ava", model.PlatformTenant, model.PlatformOrg, "manage_users", "allow"}, // the platform operator does + }}, + {"S10", []row{ // portable certification, per-tenant delegation + {"jordan", "acme", "asset:acme-dal-01", "service_advanced", "allow"}, + {"jordan", "lonestar", "asset:lonestar-ftw-01", "service_advanced", "allow"}, + {"jordan", "acme", "asset:lonestar-ftw-01", "service_advanced", "deny"}, + {"jordan", "lonestar", "asset:acme-dal-01", "service_advanced", "deny"}, + }}, + {"S11", []row{ // attribute-based resource sets + {"quinn", "acme", "asset:acme-hou-01", "operate", "allow"}, // region South + {"quinn", "acme", "asset:acme-phx-01", "operate", "deny"}, // region West + {"riley", "acme", "asset:acme-dal-01", "view", "allow"}, // Acct1 + {"riley", "acme", "asset:acme-dal-02", "view", "deny"}, // Acct2 + }}, + {"S12", []row{ // attribute rules never cross tenants + {"quinn", "acme", "asset:lonestar-ftw-01", "view", "deny"}, // South, but Lone Star's + {"quinn", "lonestar", "asset:lonestar-ftw-01", "view", "deny"}, // no role in Lone Star + {"ava", "lonestar", "asset:acme-dal-01", "view", "deny"}, // even the platform operator: one organization at a time + }}, + {"S13", []row{ // lifecycle state + {"ava", "acme", "asset:acme-phx-02", "view", "allow"}, // unassigned: platform staff + {"olivia", "acme", "asset:acme-phx-02", "view", "deny"}, // and nobody else + {"riley", "acme", "asset:acme-phx-02", "view", "deny"}, // not even through an attribute rule + {"sam", "acme", "asset:acme-dal-03", "view", "allow"}, // decommissioned: view + {"sam", "acme", "asset:acme-dal-03", "decommission", "allow"}, + {"ivy", "acme", "asset:acme-dal-03", "service_basic", "deny"}, + }}, + {"S14", []row{ // time-boxed access: no grant, no access + {"pat", "acme", "asset:acme-hou-01", "view", "deny"}, + }}, +} + +var Checks = func() []Check { + var all []Check + for _, group := range byScenario { + for n, r := range group.rows { + all = append(all, Check{ + ID: fmt.Sprintf("%s-%d", group.scenario, n+1), User: r.user, ActingOrg: r.org, Resource: r.resource, + Action: r.action, Expect: r.expect, + }) + } + } + return all +}() diff --git a/fleet-ops-demo/internal/checks/checks_test.go b/fleet-ops-demo/internal/checks/checks_test.go new file mode 100644 index 0000000..6fc86a5 --- /dev/null +++ b/fleet-ops-demo/internal/checks/checks_test.go @@ -0,0 +1,73 @@ +package checks + +import ( + "fmt" + "os" + "strings" + "testing" + + "fleetops/internal/model" +) + +// Every pre-flight check names a seeded person, organization and resource, so a typo can't turn a +// check into a deny that passes for the wrong reason (the tenant rule denies unknown resources). +func TestChecksNameSeededData(t *testing.T) { + seen := map[string]bool{} + for _, c := range Checks { + t.Run(c.ID, func(t *testing.T) { + if seen[c.ID] { + t.Errorf("duplicate check ID %s", c.ID) + } + seen[c.ID] = true + if _, ok := model.UserOf(c.User); !ok { + t.Errorf("unknown person %q", c.User) + } + if _, ok := model.TenantOf(c.ActingOrg); !ok { + t.Errorf("unknown organization %q", c.ActingOrg) + } + if _, ok := model.SeedByID[c.Resource]; !ok { + t.Errorf("unknown resource %q", c.Resource) + } + if c.Expect != "allow" && c.Expect != "deny" { + t.Errorf("expect is %q, want allow or deny", c.Expect) + } + }) + } +} + +// The README states how many checks `make verify` runs. +func TestReadmeCountsTheChecks(t *testing.T) { + readme, err := os.ReadFile("../../README.md") + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("(%d checks)", len(Checks)) + if !strings.Contains(string(readme), want) { + t.Errorf("README.md doesn't say %q", want) + } +} + +// Scenario 7: acting for the operating organization, a role the owner granted doesn't count. +func TestOperatorChecksCoverOwnerGrantedRoles(t *testing.T) { + want := map[string]bool{ + "ivy lonestar asset:acme-dal-04 service_basic deny": false, + "jordan lonestar asset:acme-dal-04 service_advanced deny": false, + "lena lonestar asset:acme-dal-04 operate allow": false, + "quinn acme asset:acme-dal-04 operate deny": false, + "sam acme asset:acme-dal-04 decommission allow": false, + "lena lonestar asset:acme-dal-04 decommission deny": false, + "ivy acme asset:acme-dal-04 service_basic deny": false, + "jordan lonestar asset:lonestar-ftw-01 service_advanced allow": false, + } + for _, c := range Checks { + key := strings.Join([]string{c.User, c.ActingOrg, c.Resource, c.Action, c.Expect}, " ") + if _, ok := want[key]; ok { + want[key] = true + } + } + for key, found := range want { + if !found { + t.Errorf("no pre-flight check %q", key) + } + } +} diff --git a/fleet-ops-demo/internal/explain/explain.go b/fleet-ops-demo/internal/explain/explain.go new file mode 100644 index 0000000..dec5923 --- /dev/null +++ b/fleet-ops-demo/internal/explain/explain.go @@ -0,0 +1,342 @@ +// Package explain turns a decision and the PDP's debug output into what the log shows: one +// sentence, the relationship route or the rule's conditions, and the tenant-isolation verdict. +package explain + +import ( + "fmt" + "strings" + "time" + + "fleetops/internal/model" + "fleetops/internal/pdp" + "fleetops/internal/schema" +) + +var actionPhrases = map[string]string{ + "view": "view", + "operate": "operate", + "service_basic": "do basic service on", + "service_advanced": "do advanced service on", + "decommission": "decommission", + "assign_roles": "assign roles on", + "manage_users": "manage users of", + "view_activity": "view the activity of", +} + +// Condition is one rule condition with the value the PDP evaluated. +type Condition struct { + Label string `json:"label"` + OK bool `json:"ok"` + Actual any `json:"actual"` +} + +type Explanation struct { + Reason string `json:"reason"` + // Route is the relationship route, origin first ("role on Name"); nil when none. + Route []string `json:"route"` + // Rule is the ABAC rule's conditions; nil when the action has no rule. + Rule []Condition `json:"rule"` + // ActingOrg is the tenant-isolation rule's code: same_org, operator_org or acting_org_mismatch. + ActingOrg string `json:"actingOrg"` + // FleetRule is the fleet rule that blocked the decision (unassigned, decommissioned, + // certification_missing, operated_by_other_org), if any. + FleetRule string `json:"fleetRule"` + // Grant names the custom grant that allowed the decision: operator_org or breakglass. + Grant string `json:"grant"` + // PolicyVersion is the version of the custom rules that decided (scenario 17). + PolicyVersion string `json:"policyVersion"` +} + +type hop struct{ resource, role, name string } + +// route follows allowing_roles from the role that allowed down to the assignment it came from: +// role_derivation sources nest down to a role_assignment. Returns the origin first. +func route(allowingRole map[string]any, nameOf func(string) string) []hop { + hops := []hop{{resource: str(allowingRole, "resource"), role: lastPart(str(allowingRole, "role"))}} + node := allowingRole + for { + var derived map[string]any + for _, s := range list(node, "sources") { + if src, ok := s.(map[string]any); ok && str(src, "type") == "role_derivation" { + derived = src + break + } + } + if derived == nil { + break + } + hops = append(hops, hop{resource: str(derived, "resource"), role: lastPart(str(derived, "role"))}) + node = derived + } + out := make([]hop, len(hops)) + for n, h := range hops { + h.name = nameOf(h.resource) + out[len(hops)-1-n] = h + } + return out +} + +var operators = map[string]func(actual, value any) bool{ + "array_contains": func(actual, value any) bool { + items, ok := actual.([]any) + if !ok { + return false + } + for _, item := range items { + if item == value { + return true + } + } + return false + }, + "equals": func(actual, value any) bool { + a, aNum := number(actual) + b, bNum := number(value) + if aNum && bNum { + return a == b + } + return actual == value + }, +} + +var labels = map[string]func(v any) string{ + "user.roles": func(v any) string { return fmt.Sprintf("Org-level role %v in the acting organization", v) }, + "user.certifications": func(v any) string { + return fmt.Sprintf("Holds the %s certification", strings.Replace(fmt.Sprint(v), "tier", "tier ", 1)) + }, + "resource.lifecycle_state": func(v any) string { return fmt.Sprintf("Truck is %v", v) }, + "resource.region": func(v any) string { return fmt.Sprintf("Truck's region is %v", v) }, + "resource.served_account": func(v any) string { return fmt.Sprintf("Truck serves %v", v) }, +} + +// label words a condition; conditions without a wording of their own get a generic one. +func label(ref string, value any) string { + if f, ok := labels[ref]; ok { + return f(value) + } + return fmt.Sprintf("%s is %v", ref, value) +} + +// rule re-evaluates an attribute rule's conditions on the attributes the PDP reported, so the log +// shows exactly which input made the rule match or miss. Of the rules for this permission it picks +// the one that allowed, or else one whose org-level role the person holds; none when the person is +// outside every rule. +func rule(c pdp.Check, debug map[string]any, allowedByRule bool) []Condition { + request := obj(debug, "request") + if request == nil { + return nil + } + attrs := map[string]map[string]any{ + "user": obj(obj(request, "user"), "attributes"), + "resource": obj(obj(request, "resource"), "attributes"), + } + var inScope []Condition + for _, r := range schema.ConditionSetRules { + if r.Permission != c.Resource.Type+":"+c.Action { + continue + } + rows, matched, holdsRole := evaluate(r, attrs) + if allowedByRule && matched { + return rows + } + if !allowedByRule && holdsRole && inScope == nil { + inScope = rows + } + } + return inScope +} + +// evaluate checks one rule's conditions. holdsRole: the person holds the rule's org-level role. +func evaluate(r schema.ConditionSetRule, attrs map[string]map[string]any) (rows []Condition, matched, holdsRole bool) { + matched, holdsRole = true, true + for _, setKey := range []string{r.UserSet, r.ResourceSet} { + cs, _ := schema.ConditionSetByKey(setKey) + for _, cond := range cs.AllOf { + o, attr, _ := strings.Cut(cond.Ref, ".") + actual := attrs[o][attr] + ok := operators[cond.Op](actual, cond.Value) + matched = matched && ok + if cond.Ref == "user.roles" { + holdsRole = holdsRole && ok + } + rows = append(rows, Condition{Label: label(cond.Ref, cond.Value), OK: ok, Actual: actual}) + } + } + return rows, matched, holdsRole +} + +// phraseOf words an action for a sentence: "do basic service on". +func phraseOf(action string) string { + if phrase := actionPhrases[action]; phrase != "" { + return phrase + } + return action +} + +// Unavailable explains a decision whose debug output couldn't be had: the verdict, and why no reason +// is shown, never a guessed one. why is lowercase, without a final period. +func Unavailable(allow bool, c pdp.Check, nameOf func(string) string, why string) Explanation { + verdict := "can" + if !allow { + verdict = "can't" + } + return Explanation{Reason: fmt.Sprintf("%s %s %s %s. No reason to show: %s.", model.UserName(c.User), verdict, phraseOf(c.Action), nameOf(c.Resource.ID()), why)} +} + +// Explain builds the log's explanation of one decision. nameOf names an instance by "type:key". An +// empty debug gives only the verdict (see Unavailable). +func Explain(allow bool, debug map[string]any, c pdp.Check, nameOf func(string) string) Explanation { + if len(debug) == 0 { + return Unavailable(allow, c, nameOf, "the PDP answered without its debug output (is PDP_DEBUG=true set on its container?)") + } + user := model.UserName(c.User) + res := c.Resource + target := nameOf(res.ID()) + phrase := phraseOf(c.Action) + acting := obj(debug, "acting_org") + rebac, rbac, abac := obj(debug, "rebac"), obj(debug, "rbac"), obj(debug, "abac") + var hops []hop + if allowing := list(rebac, "allowing_roles"); len(allowing) > 0 { + if first, ok := allowing[0].(map[string]any); ok { + hops = route(first, nameOf) + } + } + conditions := rule(c, debug, allow && boolean(abac, "allow")) + blocked := acting != nil && !boolean(acting, "allow") + fleet := obj(debug, "fleet_rules") + var violation map[string]any + if v := list(fleet, "violations"); len(v) > 0 { + violation, _ = v[0].(map[string]any) + } + grant := str(fleet, "granted_by") + + var headline string + switch { + case allow && boolean(rebac, "allow") && len(hops) > 0: + origin := hops[0] + role := strings.ReplaceAll(origin.role, "_", " ") + levels := len(hops) - 1 + if levels > 0 { + plural := "s" + if levels == 1 { + plural = "" + } + headline = fmt.Sprintf("%s can %s %s through relationships: %s on %s, derived %d level%s down.", user, phrase, target, role, origin.name, levels, plural) + } else { + headline = fmt.Sprintf("%s can %s %s through a role granted on it directly: %s.", user, phrase, target, role) + } + case allow && boolean(rbac, "allow"): + var roles []string + for _, r := range list(rbac, "allowing_roles") { + roles = append(roles, strings.ReplaceAll(fmt.Sprint(r), "_", " ")) + } + headline = fmt.Sprintf("%s can %s %s with the org-level role %s in %s.", user, phrase, target, strings.Join(roles, ", "), model.OrgName(res.Tenant)) + case allow && boolean(abac, "allow"): + headline = fmt.Sprintf("%s can %s %s: every condition of the rule matched.", user, phrase, target) + case allow && grant == "operator_org": + headline = fmt.Sprintf("%s can %s %s: %s operates it for %s, and %s holds a role in %s.", + user, phrase, target, model.OrgName(res.Tenant), model.OrgName(str(acting, "resource_org")), user, model.OrgName(res.Tenant)) + case allow && grant == "breakglass": + headline = fmt.Sprintf("%s can %s %s under a break-glass grant%s.", user, phrase, target, breakglassText(fleet)) + case allow: + headline = fmt.Sprintf("%s can %s %s.", user, phrase, target) + case blocked && str(acting, "code") == "unknown_resource": + headline = fmt.Sprintf("%s isn't in the PDP's data, so the PDP can't tie it to an organization and denies.", target) + case blocked: + headline = fmt.Sprintf("%s belongs to %s, and %s is acting for %s. Access held through other organizations doesn't apply.", + target, model.OrgName(str(acting, "resource_org")), user, model.OrgName(str(acting, "acting_org"))) + case violation != nil: + headline = violationText(violation, user, phrase, target) + case conditions != nil: + failed := 0 + for _, r := range conditions { + if !r.OK { + failed++ + } + } + verb := "are" + if failed == 1 { + verb = "is" + } + headline = fmt.Sprintf("%s can't %s %s: %d of the rule's %d conditions %s not met.", user, phrase, target, failed, len(conditions), verb) + default: + headline = fmt.Sprintf("No role, relationship or rule in %s lets %s %s %s.", model.OrgName(res.Tenant), user, phrase, target) + } + + e := Explanation{Reason: headline, Rule: conditions, ActingOrg: str(acting, "code"), Grant: grant, PolicyVersion: str(fleet, "policy_version")} + if violation != nil && !allow { + e.FleetRule = str(violation, "code") + } + for _, h := range hops { + e.Route = append(e.Route, strings.ReplaceAll(h.role, "_", " ")+" on "+h.name) + } + return e +} + +// violationText words a fleet rule that blocked the decision. +func violationText(v map[string]any, user, phrase, target string) string { + switch str(v, "code") { + case "unassigned": + return fmt.Sprintf("%s is unassigned: only the platform staff can see or use it.", target) + case "decommissioned": + return fmt.Sprintf("%s is decommissioned: only view and decommission are allowed.", target) + case "operated_by_other_org": + return fmt.Sprintf("%s is operated by %s: operational actions belong to the operating organization, not the owner.", target, model.OrgName(str(v, "operator_org"))) + case "certification_missing": + return fmt.Sprintf("%s can't %s %s: %s.", user, phrase, target, str(v, "reason")) + } + return fmt.Sprintf("%s can't %s %s: %s.", user, phrase, target, str(v, "reason")) +} + +// breakglassText describes the active break-glass grant: " for incident INC-1042, until 15:31 UTC". +func breakglassText(fleet map[string]any) string { + grants := list(fleet, "breakglass") + if len(grants) == 0 { + return "" + } + g, _ := grants[0].(map[string]any) + until := "" + if secs, ok := number(g["expires_at"]); ok { + until = ", until " + time.Unix(int64(secs), 0).Format("15:04") + } + return fmt.Sprintf(" for incident %s%s", str(g, "incident_id"), until) +} + +// ---- Reading the PDP's JSON ------------------------------------------------------------------------ + +func obj(m map[string]any, key string) map[string]any { + v, _ := m[key].(map[string]any) + return v +} + +func list(m map[string]any, key string) []any { + v, _ := m[key].([]any) + return v +} + +func str(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +func boolean(m map[string]any, key string) bool { + v, _ := m[key].(bool) + return v +} + +func lastPart(role string) string { + parts := strings.Split(role, "#") + return parts[len(parts)-1] +} + +func number(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} diff --git a/fleet-ops-demo/internal/explain/explain_test.go b/fleet-ops-demo/internal/explain/explain_test.go new file mode 100644 index 0000000..406f244 --- /dev/null +++ b/fleet-ops-demo/internal/explain/explain_test.go @@ -0,0 +1,120 @@ +package explain + +import ( + "strings" + "testing" + + "fleetops/internal/model" + "fleetops/internal/pdp" +) + +func nameOf(id string) string { + if i, ok := model.SeedByID[id]; ok { + return i.Name + } + return id +} + +// Debug payloads in the shape the PDP returns (pdp-v2 0.9.16, with the custom rules' debug merged +// in by root.rego), cut down to the fields Explain reads. +func sameOrg(org string) map[string]any { + return map[string]any{"allow": true, "code": "same_org", "acting_org": org, "resource_org": org} +} + +func TestExplain(t *testing.T) { + for name, tc := range map[string]struct { + allow bool + check pdp.Check + debug map[string]any + reason string // a part of the expected reason + code string + fleet string + grant string + }{ + "relationship route, origin first": { + allow: true, + check: pdp.NewCheck("rachel", "view", "asset:acme-hou-01", "acme"), + debug: map[string]any{ + "rebac": map[string]any{"allow": true, "allowing_roles": []any{map[string]any{ + "resource": "asset:acme-hou-01", "role": "asset#site_manager", + "sources": []any{map[string]any{ + "type": "role_derivation", "resource": "site:acme-houston", "role": "site#site_manager", + "sources": []any{map[string]any{"type": "role_derivation", "resource": "region:acme-south", "role": "region#region_manager"}}, + }}, + }}}, + "acting_org": sameOrg("acme"), + }, + reason: "Rachel can view Truck HOU-01 through relationships: region manager on South, derived 2 levels down.", + code: "same_org", + }, + "another organization's resource": { + check: pdp.NewCheck("quinn", "view", "asset:lonestar-ftw-01", "acme"), + debug: map[string]any{"acting_org": map[string]any{ + "allow": false, "code": "acting_org_mismatch", "acting_org": "acme", "resource_org": "lonestar", + }}, + reason: "Truck FTW-01 belongs to Lone Star Logistics, and Quinn is acting for Acme Freight.", + code: "acting_org_mismatch", + }, + "a fleet rule": { + check: pdp.NewCheck("ivy", "service_advanced", "asset:acme-dal-01", "acme"), + debug: map[string]any{ + "acting_org": sameOrg("acme"), + "fleet_rules": map[string]any{"allow": false, "violations": []any{map[string]any{ + "code": "certification_missing", "reason": "advanced service needs a tier 3 certification", + }}}, + }, + reason: "Ivy can't do advanced service on Truck DAL-01: advanced service needs a tier 3 certification.", + code: "same_org", + fleet: "certification_missing", + }, + "the operator's grant": { + allow: true, + check: pdp.NewCheck("lena", "operate", "asset:acme-dal-04", "lonestar"), + debug: map[string]any{ + "acting_org": map[string]any{"allow": true, "code": "operator_org", "acting_org": "lonestar", "resource_org": "acme"}, + "fleet_rules": map[string]any{"allow": true, "granted_by": "operator_org", "policy_version": "v1"}, + }, + reason: "Lone Star Logistics operates it for Acme Freight", + code: "operator_org", + grant: "operator_org", + }, + "nothing allows it": { + check: pdp.NewCheck("sam", "view", "asset:acme-hou-01", "acme"), + debug: map[string]any{"rebac": map[string]any{"allow": false}, "acting_org": sameOrg("acme")}, + reason: "No role, relationship or rule in Acme Freight lets Sam view Truck HOU-01.", + code: "same_org", + }, + } { + t.Run(name, func(t *testing.T) { + e := Explain(tc.allow, tc.debug, tc.check, nameOf) + if !strings.Contains(e.Reason, tc.reason) { + t.Errorf("reason %q, want it to contain %q", e.Reason, tc.reason) + } + if e.ActingOrg != tc.code || e.FleetRule != tc.fleet || e.Grant != tc.grant { + t.Errorf("got actingOrg %q, fleetRule %q, grant %q", e.ActingOrg, e.FleetRule, e.Grant) + } + }) + } +} + +// Without the PDP's debug output there is nothing to explain from: the verdict only, never a +// reason guessed from the missing fields (a denial for another organization's truck used to read +// "No role, relationship or rule in ... lets ..."). +func TestExplainWithoutDebug(t *testing.T) { + c := pdp.NewCheck("sam", "view", "asset:acme-hou-01", "lonestar") + for name, debug := range map[string]map[string]any{"nil": nil, "empty": {}} { + t.Run(name, func(t *testing.T) { + deny := Explain(false, debug, c, nameOf) + if !strings.HasPrefix(deny.Reason, "Sam can't view Truck HOU-01. No reason to show: ") || strings.Contains(deny.Reason, "No role, relationship or rule") { + t.Errorf("deny: %q", deny.Reason) + } + if allow := Explain(true, debug, c, nameOf); !strings.HasPrefix(allow.Reason, "Sam can view Truck HOU-01. No reason to show: ") { + t.Errorf("allow: %q", allow.Reason) + } + }) + } + e := Unavailable(false, c, nameOf, "the PDP's debug output couldn't be read: POST /allowed: 500 Internal Server Error") + if e.Reason != "Sam can't view Truck HOU-01. No reason to show: the PDP's debug output couldn't be read: POST /allowed: 500 Internal Server Error." { + t.Errorf("Unavailable: %q", e.Reason) + } +} diff --git a/fleet-ops-demo/internal/model/model.go b/fleet-ops-demo/internal/model/model.go new file mode 100644 index 0000000..bb56f6a --- /dev/null +++ b/fleet-ops-demo/internal/model/model.go @@ -0,0 +1,336 @@ +// Package model is the seed data for Fleet Ops: the smallest dataset that shows the demo's +// scenarios 1 to 17 on its fleet model (see the README). +// +// The sync command writes it to Permit. The app reads authorization data back from Permit and takes +// names, team members, delivery lanes and starting activity from here. +// +// Tenancy: every customer organization is a Permit tenant, and so is Fleet Ops, the platform operator, +// whose org admins are platform staff. The acting organization is passed as the tenant on every +// check; the custom tenant-isolation rule denies a check about another organization's resource. +// Hierarchy (ReBAC): org -> (sub-org ->) region -> site -> asset (truck), plus team under site. +package model + +import ( + "fmt" + "strings" +) + +type Tenant struct { + Key, Name, Description string + Partner bool // a partner organization (maintenance company), not a carrier + Platform bool // Fleet Ops, the platform operator +} + +type User struct { + Key, FirstName, LastName string + // HomeTenant is the organization that employs the person. "freightline" (a broker) holds no + // Permit tenant of its own. + HomeTenant string + Certifications []string + Title, Email string +} + +// Instance is a resource instance. Parent is the instance it hangs under, written to Permit as a +// `parent` relationship tuple. +type Instance struct { + Type, Key, Tenant, Name, Parent string + Attributes map[string]any +} + +func (i Instance) ID() string { return i.Type + ":" + i.Key } + +// Tuple is one relationship tuple: Subject --Relation--> Object. +type Tuple struct{ Subject, Relation, Object, Tenant string } + +// RoleAssignment is an org-level (tenant) role when ResourceInstance is empty, else a role on that +// instance ("type:key"). +type RoleAssignment struct{ User, Role, ResourceInstance, Tenant string } + +// PlatformTenant is Fleet Ops, the platform operator, which manages all the tenants. Its org admins +// are platform staff; platform-only actions are checked on its org. +const ( + PlatformTenant = "platform" + PlatformOrg = "org:" + PlatformTenant +) + +var Tenants = []Tenant{ + {Key: "acme", Name: "Acme Freight", Description: "Customer (tenant A)"}, + {Key: "lonestar", Name: "Lone Star Logistics", Description: "Customer (tenant B)"}, + {Key: "summit", Name: "Summit Freight Group", Description: "Customer with two sub-organizations, Summit East and Summit West (scenario 3)"}, + {Key: "mesa", Name: "Mesa Fleet Services", Description: "Maintenance partner (tenant P)", Partner: true}, + {Key: PlatformTenant, Name: "Fleet Ops", Description: "Platform operator: manages every customer organization", Platform: true}, +} + +// homeNames names the employers that are not Permit tenants. +var homeNames = map[string]string{"freightline": "Freightline Brokers"} + +var Users = func() []User { + people := []struct { + key, first, last, home string + certs []string + title string + }{ + {"ava", "Ava", "Stone", PlatformTenant, nil, "Platform operator at Fleet Ops"}, + {"olivia", "Olivia", "Chen", "acme", nil, "Org admin at Acme Freight"}, + {"rachel", "Rachel", "Reyes", "acme", nil, "Region manager for South at Acme Freight"}, + {"sam", "Sam", "Ortiz", "acme", nil, "Site manager for Dallas, supervisor of the night shift"}, + {"ivy", "Ivy", "Walker", "acme", []string{"tier1"}, "Technician at Dallas with tier 1, night shift"}, + {"quinn", "Quinn", "Ellis", "acme", nil, "Operator on Acme's South trucks, day shift"}, + {"riley", "Riley", "Shaw", "acme", nil, "Viewer on Acme's Acct1 trucks"}, + {"lena", "Lena", "Park", "lonestar", nil, "Org admin at Lone Star Logistics"}, + {"avery", "Avery", "Cole", "summit", nil, "Org admin at Summit Freight Group"}, + {"jamie", "Jamie", "Ortega", "summit", nil, "Org admin of Summit East"}, + {"pat", "Pat", "Morgan", "mesa", []string{"tier1", "tier2"}, "Partner technician at Mesa Fleet Services"}, + {"jordan", "Jordan", "Lee", "mesa", []string{"tier1", "tier3"}, "Contract technician at Mesa, delegated by Acme and Lone Star"}, + {"skyler", "Skyler", "Grant", "freightline", nil, "Freight broker at Freightline (shared trucks only)"}, + } + users := make([]User, len(people)) + for n, p := range people { + certs := p.certs + if certs == nil { + certs = []string{} + } + users[n] = User{Key: p.key, FirstName: p.first, LastName: p.last, HomeTenant: p.home, Certifications: certs, Title: p.title, Email: fmt.Sprintf("%s@%s.example", p.key, p.home)} + } + return users +}() + +// ---- Resource instances ------------------------------------------------------------------------- + +var Orgs = []Instance{ + {Type: "org", Key: "acme", Tenant: "acme", Name: "Acme Freight"}, + {Type: "org", Key: "lonestar", Tenant: "lonestar", Name: "Lone Star Logistics"}, + // Scenario 3: a group with two sub-organizations, each owning its own tree. + {Type: "org", Key: "summit", Tenant: "summit", Name: "Summit Freight Group"}, + {Type: "org", Key: "summit-east", Tenant: "summit", Name: "Summit East", Parent: "org:summit"}, + {Type: "org", Key: "summit-west", Tenant: "summit", Name: "Summit West", Parent: "org:summit"}, + {Type: "org", Key: "mesa", Tenant: "mesa", Name: "Mesa Fleet Services"}, + {Type: "org", Key: PlatformTenant, Tenant: PlatformTenant, Name: "Fleet Ops"}, +} + +var Regions = []Instance{ + {Type: "region", Key: "acme-south", Tenant: "acme", Name: "South", Parent: "org:acme"}, + {Type: "region", Key: "acme-west", Tenant: "acme", Name: "West", Parent: "org:acme"}, + {Type: "region", Key: "lonestar-south", Tenant: "lonestar", Name: "South", Parent: "org:lonestar"}, + {Type: "region", Key: "summit-east-main", Tenant: "summit", Name: "East", Parent: "org:summit-east"}, + {Type: "region", Key: "summit-west-main", Tenant: "summit", Name: "West", Parent: "org:summit-west"}, +} + +var Sites = []Instance{ + // Scenarios 1, 2 and 4: two sibling sites in one region. + {Type: "site", Key: "acme-dallas", Tenant: "acme", Name: "Dallas terminal", Parent: "region:acme-south"}, + {Type: "site", Key: "acme-houston", Tenant: "acme", Name: "Houston terminal", Parent: "region:acme-south"}, + {Type: "site", Key: "acme-phoenix", Tenant: "acme", Name: "Phoenix terminal", Parent: "region:acme-west"}, + {Type: "site", Key: "lonestar-fortworth", Tenant: "lonestar", Name: "Fort Worth terminal", Parent: "region:lonestar-south"}, + {Type: "site", Key: "summit-atlanta", Tenant: "summit", Name: "Atlanta terminal", Parent: "region:summit-east-main"}, + {Type: "site", Key: "summit-denver", Tenant: "summit", Name: "Denver terminal", Parent: "region:summit-west-main"}, +} + +// Teams: scenario 5. Both teams work at Dallas; Sam supervises the night shift only. +var Teams = []Instance{ + {Type: "team", Key: "acme-dallas-night", Tenant: "acme", Name: "Dallas night shift", Parent: "site:acme-dallas"}, + {Type: "team", Key: "acme-dallas-day", Tenant: "acme", Name: "Dallas day shift", Parent: "site:acme-dallas"}, +} + +// TeamMembers is app data: who works on which team. Permit decides who may view a team's activity. +var TeamMembers = map[string][]string{ + "team:acme-dallas-night": {"ivy"}, + "team:acme-dallas-day": {"quinn"}, +} + +func truck(tenant, site, code, region string, extra map[string]any) Instance { + attrs := map[string]any{ + "region": region, + "served_account": "Acct1", + "lifecycle_state": "active", + "owner_org": tenant, + "operator_org": tenant, + } + for k, v := range extra { + attrs[k] = v + } + return Instance{ + Type: "asset", + Key: tenant + "-" + strings.ToLower(code), + Tenant: tenant, + Name: "Truck " + code, + Parent: "site:" + site, + Attributes: attrs, + } +} + +var Assets = []Instance{ + // Scenario 6: the partner technician's two named trucks; DAL-01 later moves to Lone Star. + // Scenario 4: DAL-02 moves to Houston. Scenario 11: DAL-02 serves Acct2. + truck("acme", "acme-dallas", "DAL-01", "South", nil), + truck("acme", "acme-dallas", "DAL-02", "South", map[string]any{"served_account": "Acct2"}), + // Scenario 13: decommissioned. + truck("acme", "acme-dallas", "DAL-03", "South", map[string]any{"lifecycle_state": "decommissioned"}), + // Scenario 7: owned by Acme, operated by Lone Star. + truck("acme", "acme-dallas", "DAL-04", "South", map[string]any{"operator_org": "lonestar"}), + // Scenario 1: the sibling site's truck. Scenario 8: shared with the broker. + truck("acme", "acme-houston", "HOU-01", "South", nil), + // Scenario 11: region West. + truck("acme", "acme-phoenix", "PHX-01", "West", nil), + // Scenario 13: not yet assigned to a customer; only platform staff see it. + truck("acme", "acme-phoenix", "PHX-02", "West", map[string]any{"lifecycle_state": "unassigned"}), + // Scenarios 8, 10 and 12: Lone Star's truck, also in region South. + truck("lonestar", "lonestar-fortworth", "FTW-01", "South", nil), + // Scenario 3: one truck in each sub-organization. Scenario 8: ATL-01 is shared with the broker. + truck("summit", "summit-atlanta", "ATL-01", "East", nil), + truck("summit", "summit-denver", "DEN-01", "West", nil), +} + +// Lanes are delivery lanes by truck code prefix, offered when dispatching a truck. +var Lanes = map[string][]string{ + "DAL": {"Dallas → Houston", "Dallas → Fort Worth"}, + "HOU": {"Houston → Dallas", "Houston → San Antonio"}, + "PHX": {"Phoenix → El Paso", "Phoenix → Tucson"}, + "FTW": {"Fort Worth → El Paso", "Fort Worth → Houston"}, + "ATL": {"Atlanta → Charlotte", "Atlanta → Nashville"}, + "DEN": {"Denver → Salt Lake City", "Denver → Albuquerque"}, +} + +// CodeOf is a truck's code, e.g. "DAL-01" for "Truck DAL-01". +func CodeOf(truck Instance) string { return strings.TrimPrefix(truck.Name, "Truck ") } + +var Instances = func() []Instance { + var all []Instance + for _, group := range [][]Instance{Orgs, Regions, Sites, Teams, Assets} { + all = append(all, group...) + } + return all +}() + +// Tuples: every parent link. +var Tuples = func() []Tuple { + var tuples []Tuple + for _, i := range Instances { + if i.Parent != "" { + tuples = append(tuples, Tuple{Subject: i.Parent, Relation: "parent", Object: i.ID(), Tenant: i.Tenant}) + } + } + return tuples +}() + +// ---- Role assignments --------------------------------------------------------------------------- +// Resource roles live in the tenant that owns the instance; they reach lower levels by derivation. + +var RoleAssignments = func() []RoleAssignment { + ras := []RoleAssignment{ + {User: "olivia", Role: "org_admin", ResourceInstance: "org:acme", Tenant: "acme"}, + {User: "rachel", Role: "region_manager", ResourceInstance: "region:acme-south", Tenant: "acme"}, + {User: "sam", Role: "site_manager", ResourceInstance: "site:acme-dallas", Tenant: "acme"}, + {User: "sam", Role: "team_supervisor", ResourceInstance: "team:acme-dallas-night", Tenant: "acme"}, + {User: "ivy", Role: "technician", ResourceInstance: "site:acme-dallas", Tenant: "acme"}, + // Scenario 11: org-level roles granted on attribute-based sets of assets. + {User: "quinn", Role: "operator", Tenant: "acme"}, + {User: "riley", Role: "viewer", Tenant: "acme"}, + {User: "lena", Role: "org_admin", ResourceInstance: "org:lonestar", Tenant: "lonestar"}, + // Scenario 3: the parent organization's admin, and the admin of one sub-organization. + {User: "avery", Role: "org_admin", ResourceInstance: "org:summit", Tenant: "summit"}, + {User: "jamie", Role: "org_admin", ResourceInstance: "org:summit-east", Tenant: "summit"}, + // Scenario 6: Acme gives Mesa's technician two named trucks, and nothing else. + {User: "pat", Role: "partner_technician", ResourceInstance: "asset:acme-dal-01", Tenant: "acme"}, + {User: "pat", Role: "partner_technician", ResourceInstance: "asset:acme-dal-02", Tenant: "acme"}, + // Scenario 10: one tier-3 technician, delegated by two organizations. + {User: "jordan", Role: "technician", ResourceInstance: "site:acme-dallas", Tenant: "acme"}, + {User: "jordan", Role: "technician", ResourceInstance: "site:lonestar-fortworth", Tenant: "lonestar"}, + // Scenario 8: a broker sees the trucks three organizations shared, and nothing else. + {User: "skyler", Role: "viewer", ResourceInstance: "asset:acme-hou-01", Tenant: "acme"}, + {User: "skyler", Role: "viewer", ResourceInstance: "asset:lonestar-ftw-01", Tenant: "lonestar"}, + {User: "skyler", Role: "viewer", ResourceInstance: "asset:summit-atl-01", Tenant: "summit"}, + } + // The platform operator manages every tenant: Ava is org admin of Fleet Ops (platform staff) and of + // each customer organization. The tenant-isolation rule still applies, so she works inside one + // organization at a time. + for _, t := range Tenants { + ras = append(ras, RoleAssignment{User: "ava", Role: "org_admin", ResourceInstance: "org:" + t.Key, Tenant: t.Key}) + } + return ras +}() + +// SeedActivity is what the people page shows as recent activity before anyone uses the app (scenario 5). +var SeedActivity = []struct{ User, Text string }{ + {"ivy", "Logged basic service on Truck DAL-01"}, + {"quinn", "Dispatched Truck HOU-01 on Houston → Dallas"}, + {"ivy", "Logged basic service on Truck DAL-02"}, +} + +// ---- Lookups ------------------------------------------------------------------------------------ + +// SeedByID indexes the seeded instances by "type:key". +var SeedByID = func() map[string]Instance { + m := make(map[string]Instance, len(Instances)) + for _, i := range Instances { + m[i.ID()] = i + } + return m +}() + +func TenantOf(key string) (Tenant, bool) { + for _, t := range Tenants { + if t.Key == key { + return t, true + } + } + return Tenant{}, false +} + +func UserOf(key string) (User, bool) { + for _, u := range Users { + if u.Key == key { + return u, true + } + } + return User{}, false +} + +// UserName is a person's first name. +func UserName(key string) string { + if u, ok := UserOf(key); ok { + return u.FirstName + } + return key +} + +func FullName(key string) string { + if u, ok := UserOf(key); ok { + return strings.TrimSpace(u.FirstName + " " + u.LastName) + } + return key +} + +// OrgName names an organization: a tenant, or another employer (Freightline). +func OrgName(key string) string { + if t, ok := TenantOf(key); ok { + return t.Name + } + if name, ok := homeNames[key]; ok { + return name + } + return key +} + +// PartnerOrgs are the keys of partner organizations. +var PartnerOrgs = func() map[string]bool { + m := map[string]bool{} + for _, t := range Tenants { + if t.Partner { + m[t.Key] = true + } + } + return m +}() + +// TeamOf is the team a person works on, "" when none. +func TeamOf(user string) string { + for team, members := range TeamMembers { + for _, m := range members { + if m == user { + return team + } + } + } + return "" +} diff --git a/fleet-ops-demo/internal/model/model_test.go b/fleet-ops-demo/internal/model/model_test.go new file mode 100644 index 0000000..e083566 --- /dev/null +++ b/fleet-ops-demo/internal/model/model_test.go @@ -0,0 +1,117 @@ +package model_test + +import ( + "slices" + "strings" + "testing" + + "fleetops/internal/checks" + "fleetops/internal/model" + "fleetops/internal/schema" +) + +// Every instance is of a schema type, in a seeded organization, under a parent of the type its +// relation names, in the same organization. +func TestInstancesFitTheSchema(t *testing.T) { + for _, i := range model.Instances { + res, ok := schema.ResourceByKey(i.Type) + if !ok { + t.Errorf("%s: no resource type %s", i.ID(), i.Type) + continue + } + if _, ok := model.TenantOf(i.Tenant); !ok { + t.Errorf("%s: unknown organization %s", i.ID(), i.Tenant) + } + for key := range i.Attributes { + if !slices.ContainsFunc(res.Attributes, func(a schema.Attribute) bool { return a.Key == key }) { + t.Errorf("%s: attribute %s is not in the schema", i.ID(), key) + } + } + if i.Parent == "" { + continue + } + parent, ok := model.SeedByID[i.Parent] + switch { + case !ok: + t.Errorf("%s: parent %s doesn't exist", i.ID(), i.Parent) + case parent.Tenant != i.Tenant: + t.Errorf("%s: parent %s is in another organization", i.ID(), i.Parent) + case !slices.ContainsFunc(res.Relations, func(r schema.Relation) bool { return r.Key == "parent" && r.Subject == parent.Type }): + t.Errorf("%s: a %s can't be the parent of a %s", i.ID(), parent.Type, i.Type) + } + } +} + +// Every role assignment names a seeded person, a role of the instance's type (or an org-level role) +// and the instance's own organization. +func TestRoleAssignmentsFitTheSchema(t *testing.T) { + for _, ra := range model.RoleAssignments { + if _, ok := model.UserOf(ra.User); !ok { + t.Errorf("%+v: unknown person", ra) + } + if ra.ResourceInstance == "" { + if !slices.ContainsFunc(schema.TenantRoles, func(tr schema.TenantRole) bool { return tr.Key == ra.Role }) { + t.Errorf("%+v: no org-level role %s", ra, ra.Role) + } + continue + } + i, ok := model.SeedByID[ra.ResourceInstance] + if !ok { + t.Errorf("%+v: no instance %s", ra, ra.ResourceInstance) + continue + } + if i.Tenant != ra.Tenant { + t.Errorf("%+v: the instance is in %s", ra, i.Tenant) + } + res, _ := schema.ResourceByKey(i.Type) + if !slices.ContainsFunc(res.Roles, func(r schema.Role) bool { return r.Key == ra.Role }) { + t.Errorf("%+v: %s has no role %s", ra, i.Type, ra.Role) + } + } +} + +// Team members, seed activity and the pre-flight checks name people, instances and actions that exist. +func TestReferencesExist(t *testing.T) { + for team, members := range model.TeamMembers { + if _, ok := model.SeedByID[team]; !ok { + t.Errorf("team %s doesn't exist", team) + } + for _, m := range members { + if _, ok := model.UserOf(m); !ok { + t.Errorf("team %s: unknown person %s", team, m) + } + } + } + for _, e := range model.SeedActivity { + if _, ok := model.UserOf(e.User); !ok { + t.Errorf("activity: unknown person %s", e.User) + } + } + for _, c := range checks.Checks { + if _, ok := model.UserOf(c.User); !ok { + t.Errorf("%s: unknown person %s", c.ID, c.User) + } + if _, ok := model.TenantOf(c.ActingOrg); !ok { + t.Errorf("%s: unknown organization %s", c.ID, c.ActingOrg) + } + i, ok := model.SeedByID[c.Resource] + if !ok { + t.Errorf("%s: no instance %s", c.ID, c.Resource) + continue + } + typ, _, _ := strings.Cut(c.Resource, ":") + res, _ := schema.ResourceByKey(typ) + if !slices.Contains(res.Actions, c.Action) { + t.Errorf("%s: %s is not an action of %s", c.ID, c.Action, i.Type) + } + } +} + +// Every person works for a seeded organization or a known employer. +func TestHomes(t *testing.T) { + for _, u := range model.Users { + if model.OrgName(u.HomeTenant) == u.HomeTenant { + t.Errorf("%s: unknown employer %s", u.Key, u.HomeTenant) + } + } +} diff --git a/fleet-ops-demo/internal/pdp/pdp.go b/fleet-ops-demo/internal/pdp/pdp.go new file mode 100644 index 0000000..47fb99d --- /dev/null +++ b/fleet-ops-demo/internal/pdp/pdp.go @@ -0,0 +1,479 @@ +// Package pdp makes the app's checks through the Permit Go SDK (permitClient.Check and +// permitClient.BulkCheck), the way an application makes them. +// +// The SDK returns booleans. To show why each decision came out the way it did, the app also sends the +// same checks to the PDP's /allowed or /allowed/bulk endpoint, whose debug output carries the reasons, +// and reads the PDP's loaded data and policy through its embedded OPA. +package pdp + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "slices" + "strings" + "time" + + "github.com/permitio/permit-golang/pkg/enforcement" + "github.com/permitio/permit-golang/pkg/permit" + + "fleetops/internal/permitapi" + "fleetops/internal/settings" + "fleetops/policy" +) + +// NotCaughtUpError is returned by the waits when the PDP's data did not show a change in time. Any +// other error from them means the PDP or its OPA couldn't be read, so nothing is known either way. +type NotCaughtUpError struct { + Detail string // what the data still shows, or lacks: "its data still has asset:x after 15s" +} + +func (e *NotCaughtUpError) Error() string { return "the PDP has not caught up: " + e.Detail } + +type Resource struct { + Type string `json:"type"` + Key string `json:"key"` + Tenant string `json:"tenant"` +} + +// ID is "type:key". +func (r Resource) ID() string { return r.Type + ":" + r.Key } + +type Check struct { + User string `json:"user"` + Action string `json:"action"` + Resource Resource `json:"resource"` +} + +// NewCheck builds a check. The acting organization travels as the resource's tenant. Attributes come +// from Permit's stored data, never from the request. +func NewCheck(user, action, resource, actingOrg string) Check { + typ, key, _ := strings.Cut(resource, ":") + return Check{User: user, Action: action, Resource: Resource{Type: typ, Key: key, Tenant: actingOrg}} +} + +// Request is the check as the SDK takes it. +func (c Check) Request() enforcement.CheckRequest { + return enforcement.CheckRequest{ + User: enforcement.UserBuilder(c.User).Build(), + Action: enforcement.Action(c.Action), + Resource: enforcement.ResourceBuilder(c.Resource.Type).WithKey(c.Resource.Key).WithTenant(c.Resource.Tenant).Build(), + } +} + +// Code is the SDK call for this check, as the log shows it: gofmt layout, one argument per line, so +// it reads well in the narrow log panel. +func Code(c Check) string { + return fmt.Sprintf("permitClient.Check(\n enforcement.UserBuilder(%q).Build(),\n %q,\n enforcement.ResourceBuilder(%q).\n WithKey(%q).WithTenant(%q).Build())", + c.User, c.Action, c.Resource.Type, c.Resource.Key, c.Resource.Tenant) +} + +// BulkCode is the SDK call for a bulk of checks, as the log shows it. +func BulkCode(n int) string { + return fmt.Sprintf("permitClient.BulkCheck(requests...) // %d checks", n) +} + +// Client checks through the SDK and fetches the PDP's reasons and loaded data over HTTP. +type Client struct { + Permit *permit.Client + settings settings.Settings + http *http.Client +} + +func New(s settings.Settings, p *permit.Client) *Client { + return &Client{Permit: p, settings: s, http: &http.Client{Timeout: 10 * time.Second}} +} + +func (p *Client) Check(c Check) (bool, error) { + r := c.Request() + return p.Permit.Check(r.User, r.Action, r.Resource) +} + +func (p *Client) BulkCheck(checks []Check) ([]bool, error) { + if len(checks) == 0 { + return []bool{}, nil + } + requests := make([]enforcement.CheckRequest, len(checks)) + for n, c := range checks { + requests[n] = c.Request() + } + return p.Permit.BulkCheck(requests...) +} + +// Debug is the PDP's explanation of a check: the debug block of its /allowed response. It is nil, +// with no error, when the PDP answers without one (PDP_DEBUG off). +func (p *Client) Debug(c Check) (map[string]any, error) { + var res struct { + Debug map[string]any `json:"debug"` + } + if err := p.post("/allowed", c.Request(), &res); err != nil { + return nil, err + } + return res.Debug, nil +} + +// DebugMany is the same for a bulk of checks: the debug block of each item in /allowed/bulk. +func (p *Client) DebugMany(checks []Check) ([]map[string]any, error) { + requests := make([]enforcement.CheckRequest, len(checks)) + for n, c := range checks { + requests[n] = c.Request() + } + var res struct { + Allow []struct { + Debug map[string]any `json:"debug"` + } `json:"allow"` + } + if err := p.post("/allowed/bulk", requests, &res); err != nil { + return nil, err + } + if len(res.Allow) != len(checks) { + return nil, fmt.Errorf("POST /allowed/bulk: %d answers for %d checks", len(res.Allow), len(checks)) + } + out := make([]map[string]any, len(checks)) + for n, item := range res.Allow { + out[n] = item.Debug + } + return out, nil +} + +func (p *Client) post(path string, body, into any) error { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding the body of POST %s: %w", path, err) + } + req, err := http.NewRequest(http.MethodPost, p.settings.PDPURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return p.do(req, into) +} + +func (p *Client) get(url string, into any) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + return p.do(req, into) +} + +// do sends the request with the API key. A non-200 answer is a *permitapi.HTTPError. +func (p *Client) do(req *http.Request, into any) error { + req.Header.Set("Authorization", "Bearer "+p.settings.APIKey) + res, err := p.http.Do(req) + if err != nil { + return err // *url.Error: names the method and URL, never the header + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return &permitapi.HTTPError{Method: req.Method, Path: req.URL.Path, StatusCode: res.StatusCode, Status: res.Status} + } + if into == nil { + return nil + } + if err := json.NewDecoder(res.Body).Decode(into); err != nil { + return fmt.Errorf("decoding the answer to %s %s: %w", req.Method, req.URL.Path, err) + } + return nil +} + +// OPAData is a value from the PDP's loaded data, e.g. "resource_instances/asset:acme-dal-01". It is +// nil, with no error, when the data has nothing there (OPA answers 200 without a result); an error +// means the data couldn't be read, so nothing is known about it. +func (p *Client) OPAData(path string) (any, error) { + var res struct { + Result any `json:"result"` + } + if err := p.get(p.settings.OPAURL+"/v1/data/"+path, &res); err != nil { + return nil, fmt.Errorf("reading the PDP's data: %w", err) + } + return res.Result, nil +} + +// RebuildRelationshipCache rebuilds the PDP's relationship cache from its loaded data, by evaluating +// the policy's own cache-rebuild rule. On pdp-v2 0.9.16 a schema change leaves that cache, which +// /user-permissions and AuthZEN search use, missing relationships until it is rebuilt. +func (p *Client) RebuildRelationshipCache() error { + req, err := http.NewRequest(http.MethodPost, p.settings.OPAURL+"/v1/data/permit/rebac/cache_rebuild", strings.NewReader(`{"input":{}}`)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + var res struct { + Result bool `json:"result"` + } + if err := p.do(req, &res); err != nil { + return err + } + if !res.Result { + return fmt.Errorf("cache_rebuild returned false") + } + return nil +} + +// RefetchData makes the PDP fetch all its data from Permit again, replacing what it has. +func (p *Client) RefetchData() error { + req, err := http.NewRequest(http.MethodPost, p.settings.PDPURL+"/data-updater/trigger", nil) + if err != nil { + return err + } + return p.do(req, nil) +} + +// HasParent reports whether the PDP's loaded data links object ("type:key") to parent ("type:key"). +func (p *Client) HasParent(object, parent string) (bool, error) { + typ, key, _ := strings.Cut(parent, ":") + data, err := p.OPAData("relationships/" + object) + if err != nil { + return false, err + } + rel, _ := data.(map[string]any) + parents, _ := rel["relation:parent"].(map[string]any) + keys, _ := parents[typ].([]any) + for _, k := range keys { + if k == key { + return true, nil + } + } + return false, nil +} + +// Settle waits until the PDP has the given parent links (object -> parent). Deleting an instance and +// creating it again with the same key makes Permit remove the old links asynchronously; when that +// reaches the PDP after the new link, the PDP loses the link. If the links don't show up within a +// few seconds, a full data refetch from Permit repairs it. Returns nil once all links are there, a +// *NotCaughtUpError that names the missing ones, or the error that stopped it reading. +func (p *Client) Settle(links map[string]string) error { + var missing []string + for _, wait := range []time.Duration{4 * time.Second, 20 * time.Second} { + for deadline := time.Now().Add(wait); ; time.Sleep(200 * time.Millisecond) { + var err error + if missing, err = p.Missing(links); err != nil || len(missing) == 0 { + return err + } + if time.Now().After(deadline) { + break + } + } + if err := p.RefetchData(); err != nil { + return fmt.Errorf("refetching the PDP's data: %w", err) + } + } + missing, err := p.Missing(links) + if err != nil || len(missing) == 0 { + return err + } + return &NotCaughtUpError{Detail: fmt.Sprintf("its data has no parent link for %s after a refetch", strings.Join(missing, ", "))} +} + +// Keep holds parent links (object -> parent) in the PDP's data. Once they are all there it watches +// them for `stable`; whenever some go missing (a deletion of the old links that reached the PDP late), +// it has the PDP refetch its data from Permit and watches again, for at most `limit`. Returns how many +// refetches it took, and nil when the links stayed, a *NotCaughtUpError when they did not, or the +// error that stopped it reading. +func (p *Client) Keep(links map[string]string, stable, limit time.Duration) (int, error) { + var complete, refetched time.Time + var missing []string + var refetchErr error + refetches := 0 + for deadline := time.Now().Add(limit); time.Now().Before(deadline); time.Sleep(500 * time.Millisecond) { + var err error + if missing, err = p.Missing(links); err != nil { + return refetches, err + } + if len(missing) == 0 { + if complete.IsZero() { + complete = time.Now() + } + if time.Since(complete) >= stable { + return refetches, nil + } + continue + } + complete = time.Time{} + if time.Since(refetched) > 10*time.Second { + if refetchErr = p.RefetchData(); refetchErr == nil { + refetches++ + } + refetched = time.Now() + } + } + detail := fmt.Sprintf("its parent links did not stay for %s within %s", stable, limit) + if len(missing) > 0 { + detail = fmt.Sprintf("its data has no parent link for %s at the end of %s", strings.Join(missing, ", "), limit) + } + if refetchErr != nil { + detail += fmt.Sprintf(" (the last data refetch failed: %v)", refetchErr) + } + return refetches, &NotCaughtUpError{Detail: detail} +} + +// WaitUnlinked waits until the PDP's data has no relationships for the instances. Returns nil when +// they are gone within the timeout, a *NotCaughtUpError when they are not, or the error that stopped +// it reading. +func (p *Client) WaitUnlinked(ids []string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + linked := "" + for _, id := range ids { + data, err := p.OPAData("relationships/" + id) + if err != nil { + return err + } + if rel, _ := data.(map[string]any); len(rel) > 0 { + linked = id + break + } + } + if linked == "" { + return nil + } + if time.Now().After(deadline) { + return &NotCaughtUpError{Detail: fmt.Sprintf("its data still links %s after %s", linked, timeout)} + } + time.Sleep(200 * time.Millisecond) + } +} + +// Missing lists the parent links (object -> parent) the PDP's data doesn't have. +func (p *Client) Missing(links map[string]string) ([]string, error) { + var out []string + for object, parent := range links { + tenant, err := p.instanceTenant(object) + if err != nil { + return nil, err + } + linked := false + if tenant != "" { + if linked, err = p.HasParent(object, parent); err != nil { + return nil, err + } + } + if !linked { + out = append(out, object) + } + } + slices.Sort(out) + return out, nil +} + +// Restart restarts the PDP container (docker compose, in the demo folder) and waits until it is +// healthy. A restarted PDP loads all its data and policy from Permit afresh. +func (p *Client) Restart() error { + cmd := exec.Command("docker", "compose", "--env-file", ".env", "restart", "pdp") + cmd.Dir = p.settings.DemoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("docker compose restart pdp: %v\n%s", err, out) + } + for range 40 { + time.Sleep(time.Second) + if p.get(p.settings.PDPURL+"/healthy", nil) == nil { + return nil + } + } + return fmt.Errorf("the PDP isn't healthy 40 s after a restart") +} + +// instanceTenant is the organization the PDP's data gives an instance; "" when the PDP doesn't have it. +// After a deletion the PDP keeps a stub without a tenant ({"attributes": {}}). +func (p *Client) instanceTenant(id string) (string, error) { + data, err := p.OPAData("resource_instances/" + id) + if err != nil { + return "", err + } + inst, _ := data.(map[string]any) + tenant, _ := inst["tenant"].(string) + return tenant, nil +} + +// WaitGone waits until the PDP's data no longer has the instances or their relationships, so a +// deletion can't reach the PDP after an instance created again with the same key. Returns nil when +// they are gone within the timeout, a *NotCaughtUpError when they are not, or the error that stopped +// it reading. +func (p *Client) WaitGone(ids []string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + present := "" + for _, id := range ids { + tenant, err := p.instanceTenant(id) + if err != nil { + return err + } + data, err := p.OPAData("relationships/" + id) + if err != nil { + return err + } + if rel, _ := data.(map[string]any); tenant != "" || len(rel) > 0 { + present = id + break + } + } + if present == "" { + return nil + } + if time.Now().After(deadline) { + return &NotCaughtUpError{Detail: fmt.Sprintf("its data still has %s after %s", present, timeout)} + } + time.Sleep(200 * time.Millisecond) + } +} + +type Status struct { + PDP bool `json:"pdp"` + // ActingOrgRule: the custom rules (tenant isolation and the fleet rules) are loaded and every + // allow in root.rego requires them. + ActingOrgRule bool `json:"actingOrgRule"` + // RulesUnknown says why the loaded policy couldn't be read (OPA unreachable, say), so + // ActingOrgRule is false for lack of an answer, not because the rules are missing. "" when read. + RulesUnknown string `json:"rulesUnknown,omitempty"` +} + +// loadedModule is the source of a policy module the PDP's OPA has loaded, "" when it has none by +// that name (OPA answers 404). +func (p *Client) loadedModule(id string) (string, error) { + var res struct { + Result struct { + Raw string `json:"raw"` + } `json:"result"` + } + if err := p.get(p.settings.OPAURL+"/v1/policies/"+id, &res); err != nil { + if permitapi.Status(err) == http.StatusNotFound { + return "", nil + } + return "", fmt.Errorf("reading the PDP's policy %s: %w", id, err) + } + return res.Result.Raw, nil +} + +// CustomPolicyVersion is the version stamped in the custom rules the PDP has loaded, "" when none. +func (p *Client) CustomPolicyVersion() (string, error) { + raw, err := p.loadedModule("custom/root.rego") + if err != nil { + return "", err + } + return policy.Version(raw), nil +} + +// Status: is the PDP up, and are the custom rules from the GitOps repo loaded and wired into root? +func (p *Client) Status() Status { + var s Status + s.PDP = p.get(p.settings.PDPURL+"/healthy", nil) == nil + // Every PDP has Permit's root.rego loaded: without it, this is not the PDP's OPA. + root, err := p.loadedModule("root.rego") + if err == nil && root == "" { + err = fmt.Errorf("the OPA at %s has no root.rego loaded; is OPA_URL the PDP's OPA?", p.settings.OPAURL) + } + if err != nil { + s.RulesUnknown = err.Error() + return s + } + custom, err := p.loadedModule("custom/root.rego") + if err != nil { + s.RulesUnknown = err.Error() + return s + } + s.ActingOrgRule = strings.Contains(custom, "acting_org_ok") && strings.Contains(custom, "fleet_rules_ok") && policy.RequiredByRoot(root) + return s +} diff --git a/fleet-ops-demo/internal/pdp/pdp_test.go b/fleet-ops-demo/internal/pdp/pdp_test.go new file mode 100644 index 0000000..ac45c4b --- /dev/null +++ b/fleet-ops-demo/internal/pdp/pdp_test.go @@ -0,0 +1,207 @@ +package pdp + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "fleetops/internal/permitapi" + "fleetops/internal/settings" + "fleetops/policy" +) + +// fakeOPA answers OPA and PDP paths from a table: path -> status and body. Unknown paths get 404. +func fakeOPA(t *testing.T, answer func(path string) (int, string)) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + status, body := answer(r.URL.Path) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return New(settings.Settings{APIKey: "permit_key_test", PDPURL: srv.URL, OPAURL: srv.URL}, nil) +} + +// unreachable is a client whose PDP and OPA refuse connections. +func unreachable(t *testing.T) *Client { + t.Helper() + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + return New(settings.Settings{APIKey: "permit_key_test", PDPURL: url, OPAURL: url}, nil) +} + +func TestOPAData(t *testing.T) { + p := fakeOPA(t, func(path string) (int, string) { + switch path { + case "/v1/data/resource_instances/asset:a": + return 200, `{"result":{"tenant":"acme"}}` + case "/v1/data/resource_instances/asset:none": + return 200, `{}` + } + return 500, `{"code":"internal_error"}` + }) + t.Run("present", func(t *testing.T) { + got, err := p.OPAData("resource_instances/asset:a") + if err != nil || got == nil { + t.Fatalf("got %v, %v", got, err) + } + }) + t.Run("absent is nil without an error", func(t *testing.T) { + got, err := p.OPAData("resource_instances/asset:none") + if err != nil || got != nil { + t.Fatalf("got %v, %v", got, err) + } + }) + t.Run("a failed read is an error, with the status", func(t *testing.T) { + _, err := p.OPAData("resource_instances/asset:broken") + if permitapi.Status(err) != 500 { + t.Fatalf("got %v, want a 500 HTTPError", err) + } + }) + t.Run("unreachable is an error", func(t *testing.T) { + if _, err := unreachable(t).OPAData("resource_instances/asset:a"); err == nil { + t.Fatal("no error from an unreachable OPA") + } + }) +} + +// A wait must never read "couldn't read" as "gone": that let a transfer re-create a truck while +// the PDP still had it, and `make setup` report the race guarded when it wasn't. +func TestWaitsFailOnReadErrors(t *testing.T) { + for name, p := range map[string]*Client{ + "unreachable": unreachable(t), + "401": fakeOPA(t, func(string) (int, string) { return 401, `{}` }), + } { + t.Run(name, func(t *testing.T) { + started := time.Now() + for wait, err := range map[string]error{ + "WaitGone": p.WaitGone([]string{"asset:a"}, 5*time.Second), + "WaitUnlinked": p.WaitUnlinked([]string{"asset:a"}, 5*time.Second), + "Settle": p.Settle(map[string]string{"asset:a": "site:s"}), + } { + if _, behind := errors.AsType[*NotCaughtUpError](err); err == nil || behind { + t.Errorf("%s: got %v, want a read error", wait, err) + } + } + if _, err := p.Keep(map[string]string{"asset:a": "site:s"}, time.Second, 5*time.Second); err == nil { + t.Error("Keep: no error") + } + if took := time.Since(started); took > 3*time.Second { + t.Errorf("read errors took %s to surface: the waits kept polling", took) + } + }) + } +} + +func TestWaitGone(t *testing.T) { + t.Run("gone once the PDP has only a stub", func(t *testing.T) { + var reads atomic.Int32 + p := fakeOPA(t, func(path string) (int, string) { + if path == "/v1/data/resource_instances/asset:a" && reads.Add(1) < 3 { + return 200, `{"result":{"tenant":"acme","attributes":{}}}` + } + if path == "/v1/data/resource_instances/asset:a" { + return 200, `{"result":{"attributes":{}}}` + } + return 200, `{}` + }) + if err := p.WaitGone([]string{"asset:a"}, 5*time.Second); err != nil { + t.Fatal(err) + } + }) + t.Run("still there after the timeout", func(t *testing.T) { + p := fakeOPA(t, func(path string) (int, string) { + return 200, `{"result":{"relation:parent":{"site":["s"]}}}` + }) + err := p.WaitGone([]string{"asset:a"}, 300*time.Millisecond) + if behind, ok := errors.AsType[*NotCaughtUpError](err); !ok || !strings.Contains(behind.Detail, "asset:a") { + t.Fatalf("got %v, want a NotCaughtUpError naming asset:a", err) + } + }) +} + +func TestStatus(t *testing.T) { + root := "package permit.root\n\ndefault allow := false\n\n" + policy.RootAllow + "\n" + custom := policy.Custom("v1") + modules := func(rootStatus int, rootBody string, customStatus int) *Client { + return fakeOPA(t, func(path string) (int, string) { + switch path { + case "/healthy": + return 200, `{}` + case "/v1/policies/root.rego": + return rootStatus, `{"result":{"raw":` + quote(rootBody) + `}}` + case "/v1/policies/custom/root.rego": + return customStatus, `{"result":{"raw":` + quote(custom) + `}}` + } + return 404, `{}` + }) + } + for name, tc := range map[string]struct { + client *Client + rules bool + unknown bool + }{ + "rules installed and required": {modules(200, root, 200), true, false}, + "custom module missing": {modules(200, root, 404), false, false}, + "root not wired": {modules(200, "package permit.root\n\nallow {\n\tpolicies.allow\n}\n", 200), false, false}, + "OPA answers 500": {modules(500, root, 500), false, true}, + "no root.rego: not the PDP's OPA": {modules(404, root, 404), false, true}, + } { + t.Run(name, func(t *testing.T) { + st := tc.client.Status() + if !st.PDP || st.ActingOrgRule != tc.rules || (st.RulesUnknown != "") != tc.unknown { + t.Fatalf("got %+v", st) + } + }) + } + t.Run("OPA unreachable: unknown, not missing", func(t *testing.T) { + st := unreachable(t).Status() + if st.ActingOrgRule || st.RulesUnknown == "" { + t.Fatalf("got %+v", st) + } + }) +} + +func TestCustomPolicyVersion(t *testing.T) { + installed := fakeOPA(t, func(string) (int, string) { + return 200, `{"result":{"raw":` + quote(policy.Custom("2026-10-13T10:00:00Z")) + `}}` + }) + if v, err := installed.CustomPolicyVersion(); err != nil || v != "2026-10-13T10:00:00Z" { + t.Fatalf("got %q, %v", v, err) + } + missing := fakeOPA(t, func(string) (int, string) { return 404, `{}` }) + if v, err := missing.CustomPolicyVersion(); err != nil || v != "" { + t.Fatalf("not loaded: got %q, %v", v, err) + } + if _, err := unreachable(t).CustomPolicyVersion(); err == nil { + t.Fatal("unreachable: no error") + } +} + +func TestDebug(t *testing.T) { + c := NewCheck("sam", "view", "asset:acme-hou-01", "acme") + if _, err := unreachable(t).Debug(c); err == nil { + t.Error("Debug: no error from an unreachable PDP") + } + if _, err := unreachable(t).DebugMany([]Check{c}); err == nil { + t.Error("DebugMany: no error from an unreachable PDP") + } + short := fakeOPA(t, func(string) (int, string) { return 200, `{"allow":[{"debug":{"rbac":{}}}]}` }) + if _, err := short.DebugMany([]Check{c, c}); err == nil { + t.Error("DebugMany: no error for one answer to two checks") + } + off := fakeOPA(t, func(string) (int, string) { return 200, `{"allow":false}` }) + if debug, err := off.Debug(c); err != nil || debug != nil { + t.Errorf("PDP_DEBUG off: got %v, %v; want nil without an error", debug, err) + } +} + +func quote(s string) string { + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\t", `\t`) + return `"` + r.Replace(s) + `"` +} diff --git a/fleet-ops-demo/internal/permitapi/permitapi.go b/fleet-ops-demo/internal/permitapi/permitapi.go new file mode 100644 index 0000000..bd7ef38 --- /dev/null +++ b/fleet-ops-demo/internal/permitapi/permitapi.go @@ -0,0 +1,137 @@ +// Package permitapi has small helpers around the SDK's REST client: a timeout on every call, retry +// when rate-limited or timed out, treat "already exists" as a result, and read every page of a list. +package permitapi + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + permiterrors "github.com/permitio/permit-golang/pkg/errors" + "github.com/permitio/permit-golang/pkg/openapi" +) + +const ( + perPage = 100 + // callTimeout bounds each API call. The SDK's facts clients (resource instances, relationship + // tuples, role assignments) have no HTTP timeout of their own, so a stalled response would + // otherwise block forever. + callTimeout = 20 * time.Second +) + +// HTTPError is a non-200 answer to a request made without the SDK: a raw list call, or a PDP or OPA +// request. It carries the status so Status, and so Call's retries and allowed statuses, see it. +type HTTPError struct { + Method, Path string + StatusCode int + Status string // the status line, e.g. "429 Too Many Requests" +} + +func (e *HTTPError) Error() string { return fmt.Sprintf("%s %s: %s", e.Method, e.Path, e.Status) } + +// Status is the HTTP status of a failed call, or 0 when the error carries none. Most SDK methods +// return a PermitError; a few (resource attributes, for one) pass on the generated client's error, +// whose message is the status line, e.g. "409 Conflict"; calls made without the SDK return HTTPError. +func Status(err error) int { + if pe, ok := errors.AsType[permiterrors.PermitError](err); ok { + return pe.StatusCode + } + if he, ok := errors.AsType[*HTTPError](err); ok { + return he.StatusCode + } + if oe, ok := errors.AsType[*openapi.GenericOpenAPIError](err); ok { + code, _ := strconv.Atoi(strings.SplitN(oe.Error(), " ", 2)[0]) + return code + } + return 0 +} + +// backoff is the wait before retry number attempt+1: 2 s, 4 s, ... 64 s. +var backoff = func(attempt int) time.Duration { return time.Duration(2< 0 { + t.Errorf("%s has attributes; the model gives attributes to assets only", r.Key) + } + } +} + +// Every permission is an action of the role's resource, and every derivation names a role that exists +// on a resource the relation points to. +func TestRolesAreConsistent(t *testing.T) { + for _, r := range Resources { + for _, role := range r.Roles { + for _, p := range role.Permissions { + if !slices.Contains(r.Actions, p) { + t.Errorf("%s#%s: %s is not an action of %s", r.Key, role.Key, p, r.Key) + } + } + for _, d := range role.DerivedFrom { + rel := slices.IndexFunc(r.Relations, func(x Relation) bool { return x.Key == d.Relation && x.Subject == d.OnResource }) + if rel < 0 { + t.Errorf("%s#%s: no relation %s to %s", r.Key, role.Key, d.Relation, d.OnResource) + } + on, _ := ResourceByKey(d.OnResource) + if !slices.ContainsFunc(on.Roles, func(x Role) bool { return x.Key == d.Role }) { + t.Errorf("%s#%s: derived from %s#%s, which doesn't exist", r.Key, role.Key, d.OnResource, d.Role) + } + } + } + } + for _, tr := range TenantRoles { + for _, p := range tr.Permissions { + typ, action, _ := strings.Cut(p, ":") + r, ok := ResourceByKey(typ) + if !ok || !slices.Contains(r.Actions, action) { + t.Errorf("org-level role %s: unknown permission %s", tr.Key, p) + } + } + } +} + +// Every rule must name sets the log's explanation can look up, including a role's own user set, and +// grant an action of the resource set's type. +func TestRulesNameKnownSets(t *testing.T) { + for _, r := range ConditionSetRules { + if _, ok := ConditionSetByKey(r.UserSet); !ok { + t.Errorf("rule %s: unknown user set %q", r.Permission, r.UserSet) + } + rs, ok := ConditionSetByKey(r.ResourceSet) + if !ok { + t.Errorf("rule %s: unknown resource set %q", r.Permission, r.ResourceSet) + continue + } + if typ, _, _ := strings.Cut(r.Permission, ":"); typ != rs.Resource { + t.Errorf("rule %s on %s, a set of %s", r.Permission, r.ResourceSet, rs.Resource) + } + if role, ok := strings.CutPrefix(r.UserSet, roleSetPrefix); ok && !slices.ContainsFunc(TenantRoles, func(tr TenantRole) bool { return tr.Key == role }) { + t.Errorf("rule %s: %s is not an org-level role", r.Permission, role) + } + } +} + +// A role's own user set is described as the one condition it holds, and is not one the sync creates. +func TestRoleSetIsTheRoleAlone(t *testing.T) { + cs, ok := ConditionSetByKey(RoleSet("operator")) + if !ok || len(cs.AllOf) != 1 { + t.Fatalf("got %+v, %v", cs, ok) + } + if c := cs.AllOf[0]; c.Ref != "user.roles" || c.Op != "array_contains" || c.Value != "operator" { + t.Errorf("condition = %+v", c) + } + if !IsRoleSet(cs.Key) { + t.Errorf("%s is not recognized as a role's set", cs.Key) + } + for _, own := range ConditionSets { + if own.Key == cs.Key || IsRoleSet(own.Key) { + t.Errorf("%s is in ConditionSets, so the sync would try to create it", own.Key) + } + } +} diff --git a/fleet-ops-demo/internal/server/actions.go b/fleet-ops-demo/internal/server/actions.go new file mode 100644 index 0000000..a93bfab --- /dev/null +++ b/fleet-ops-demo/internal/server/actions.go @@ -0,0 +1,341 @@ +package server + +import ( + "context" + "fmt" + "log" + "maps" + "slices" + "strings" + "time" + + "github.com/permitio/permit-golang/pkg/models" + + "fleetops/internal/model" + "fleetops/internal/permitapi" +) + +// actBody is a POST to one of the /act endpoints. +type actBody struct { + User string `json:"user"` + Org string `json:"org"` + Action string `json:"action"` + Lane string `json:"lane"` + Partner string `json:"partner"` + Site string `json:"site"` + Person string `json:"person"` + Role string `json:"role"` + Region string `json:"region"` + Account string `json:"account"` + // People, access and organizations pages. + Certifications []string `json:"certifications"` + Incident string `json:"incident"` + Minutes int `json:"minutes"` + Target string `json:"target"` +} + +func ptr[T any](v T) *T { return &v } + +// truckAction runs one action on a truck. Each is enforced with a PDP check before anything is written. +func (a *App) truckAction(body actBody, key string) (obj, error) { + r := a.newRequest() + truck, ok := a.instances["asset:"+key] + if !ok { + return obj{"error": "There is no such truck.", "log": r.log}, nil + } + id, user, org := truck.ID(), body.User, body.Org + site := a.siteOf(truck) + + enforce := func(label, permission, resource string) (bool, error) { + return r.checkOne(label+": enforce before writing", user, permission, resource, org) + } + fail := func(message string) (obj, error) { return obj{"error": message, "log": r.log}, nil } + logTruck := func(kind string) { + a.history[id] = append([]historyRecord{{Kind: kind, By: model.FullName(user), At: time.Now()}}, a.history[id]...) + } + + var message string + switch body.Action { + case "dispatch": + if ok, err := enforce("Dispatch "+truck.Name, "operate", id); !ok { + return r.refuse(err) + } + lane := body.Lane + if lane == "" { + lane = "Local run" + } + logTruck("Dispatched on " + lane) + message = fmt.Sprintf("Dispatched %s on %s.", truck.Name, lane) + + case "service_basic", "service_advanced": + label, kind := "Log basic service", "Basic service" + if body.Action == "service_advanced" { + label, kind = "Run advanced service", "Advanced service" + } + if ok, err := enforce(label, body.Action, id); !ok { + return r.refuse(err) + } + logTruck(kind) + message = fmt.Sprintf("%s logged on %s.", kind, truck.Name) + + case "decommission", "reactivate": + label, state := "Decommission", "decommissioned" + if body.Action == "reactivate" { + label, state = "Reactivate", "active" + } + if ok, err := enforce(label, "decommission", id); !ok { + return r.refuse(err) + } + attrs := maps.Clone(truck.Attributes) + attrs["lifecycle_state"] = state + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.ResourceInstances.Update(ctx, id, models.ResourceInstanceUpdate{Attributes: attrs}) + return err + }); err != nil { + return nil, fmt.Errorf("updating the attributes of %s: %w", id, err) + } + truck.Attributes = attrs + r.untilData(fmt.Sprintf("%s's lifecycle_state %s", truck.Name, state), "resource_instances/"+id, func(d any) bool { + return field(field(d, "attributes"), "lifecycle_state") == state + }) + message = fmt.Sprintf("%s is %s: its lifecycle_state attribute in Permit changed.", truck.Name, state) + + case "move": + // Scenario 4: re-parenting. Moving a truck takes admin rights on both sites. + target, ok := a.instances["site:"+body.Site] + if !ok || target.Tenant != truck.Tenant || target.ID() == truck.Parent { + return fail("Pick another site in the same organization.") + } + if ok, err := enforce(fmt.Sprintf("Move %s out of %s (site admin)", truck.Name, site.Name), "assign_roles", site.ID()); !ok { + return r.refuse(err) + } + if ok, err := enforce(fmt.Sprintf("Move %s into %s (site admin)", truck.Name, target.Name), "assign_roles", target.ID()); !ok { + return r.refuse(err) + } + old := site + if err := permitapi.Call(func(ctx context.Context) error { + return a.api.RelationshipTuples.Delete(ctx, *models.NewRelationshipTupleDelete(old.ID(), "parent", id)) + }); err != nil { + return nil, fmt.Errorf("unlinking %s from %s: %w", id, old.ID(), err) + } + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.RelationshipTuples.Create(ctx, models.RelationshipTupleCreate{Subject: target.ID(), Relation: "parent", Object: id, Tenant: ptr(truck.Tenant)}) + return err + }); err != nil { + return nil, fmt.Errorf("linking %s to %s: %w", id, target.ID(), err) + } + truck.Parent = target.ID() + r.untilData(fmt.Sprintf("%s's parent link to %s", truck.Name, target.Name), "relationships/"+id, func(d any) bool { + sites, _ := field(field(d, "relation:parent"), "site").([]any) + return len(sites) == 1 && sites[0] == target.Key + }) + // Who loses and who gains the truck, decided by the PDP: someone with a role on each site. + for _, s := range []*model.Instance{old, target} { + if ra, ok := a.someoneOn(s.ID(), user); ok { + question := fmt.Sprintf("After the move: can %s (%s at %s) view %s?", model.FullName(ra.User), strings.ReplaceAll(ra.Role, "_", " "), s.Name, truck.Name) + if _, err := r.checkOne(question, ra.User, "view", id, truck.Tenant); err != nil { + return nil, err + } + } + } + message = fmt.Sprintf("Moved %s to %s: one parent link replaced, no role assignment changed. Access derived from %s is gone; access derived from %s applies.", truck.Name, target.Name, old.Name, target.Name) + + case "grant", "revoke": + // Scenario 6: a partner technician on this truck only. + partner, isUser := model.UserOf(body.Partner) + if !isUser || !model.PartnerOrgs[partner.HomeTenant] { + return fail("Pick a partner technician.") + } + grant := body.Action == "grant" + label := "Revoke partner access" + if grant { + label = "Grant partner access" + } + if ok, err := enforce(label+" (site admin)", "assign_roles", site.ID()); !ok { + return r.refuse(err) + } + ra := model.RoleAssignment{User: partner.Key, Role: "partner_technician", ResourceInstance: id, Tenant: truck.Tenant} + if err := a.writeAssignment(ra, grant); err != nil { + return nil, err + } + verb := "loses" + if grant { + verb = "gains" + } + name := model.FullName(partner.Key) + if err := r.untilDecision(fmt.Sprintf("%s %s access to %s", name, verb, truck.Name), partner.Key, "view", id, truck.Tenant, grant); err != nil { + return nil, err + } + message = fmt.Sprintf("%s no longer has access to %s.", name, truck.Name) + if grant { + message = fmt.Sprintf("%s can now service %s, and only this truck, acting for %s.", name, truck.Name, model.OrgName(truck.Tenant)) + } + + case "edit_details": + // Scenario 11: change the attributes the resource sets read. Takes admin rights on the site. + region, account := body.Region, body.Account + if !slices.Contains([]string{"South", "West", "East"}, region) || !slices.Contains([]string{"Acct1", "Acct2"}, account) { + return fail("Pick a region and a served account.") + } + if ok, err := enforce(fmt.Sprintf("Edit the details of %s (site admin)", truck.Name), "assign_roles", site.ID()); !ok { + return r.refuse(err) + } + before := truck.Attributes + attrs := maps.Clone(truck.Attributes) + attrs["region"], attrs["served_account"] = region, account + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.ResourceInstances.Update(ctx, id, models.ResourceInstanceUpdate{Attributes: attrs}) + return err + }); err != nil { + return nil, fmt.Errorf("updating the attributes of %s: %w", id, err) + } + truck.Attributes = attrs + r.untilData(fmt.Sprintf("%s's region %s and served account %s", truck.Name, region, account), "resource_instances/"+id, func(d any) bool { + stored := field(d, "attributes") + return field(stored, "region") == region && field(stored, "served_account") == account + }) + // What the change does to the attribute-based rules, decided by the PDP. + if before["served_account"] != account { + for _, u := range a.withTenantRole("viewer", truck.Tenant) { + if _, err := r.checkOne(fmt.Sprintf("Can %s see %s now?", model.FullName(u), truck.Name), u, "view", id, truck.Tenant); err != nil { + return nil, err + } + } + } + if before["region"] != region { + for _, u := range a.withTenantRole("operator", truck.Tenant) { + if _, err := r.checkOne(fmt.Sprintf("Can %s operate %s now?", model.FullName(u), truck.Name), u, "operate", id, truck.Tenant); err != nil { + return nil, err + } + } + } + message = fmt.Sprintf("%s now has region %s and serves %s. Access through the attribute-based sets follows the new values.", truck.Name, region, account) + + case "transfer": + // Scenarios 6 and 13: move the truck to another organization. Platform staff only. + target, ok := a.instances["site:"+body.Site] + if !ok || target.Tenant == truck.Tenant || !a.tenants[target.Tenant] { + return fail("Pick a site in another organization.") + } + from, to := truck.Tenant, target.Tenant + if ok, err := r.platform(fmt.Sprintf("Transfer %s to %s: platform staff only, enforce before writing", truck.Name, model.OrgName(to)), user); !ok { + return r.refuse(err) + } + var grantees []string // people with a grant on this truck in the old organization + for _, ra := range a.assignments { + if ra.ResourceInstance == id && !slices.Contains(grantees, ra.User) { + grantees = append(grantees, ra.User) + } + } + // An instance can't change organization: delete it (its links and grants go with it) and create it + // again in the new one. + if err := permitapi.Call(func(ctx context.Context) error { return a.api.ResourceInstances.Delete(ctx, id) }, 404); err != nil { + return nil, fmt.Errorf("deleting %s: %w", id, err) + } + // When the PDP still has it, a late deletion may strip the new link; holdLinks below repairs that. + if err := a.pdp.WaitGone([]string{id}, 15*time.Second); err != nil { + log.Printf("transfer: creating %s again before the PDP dropped it: %v", id, err) + } + attrs := maps.Clone(truck.Attributes) + attrs["owner_org"], attrs["operator_org"], attrs["lifecycle_state"] = to, to, "active" + if err := permitapi.CreateAgain(func(ctx context.Context) error { + _, err := a.api.ResourceInstances.Create(ctx, models.ResourceInstanceCreate{Key: truck.Key, Resource: "asset", Tenant: ptr(to), Attributes: attrs}) + return err + }); err != nil { + return nil, fmt.Errorf("creating %s in %s: %w", id, to, err) + } + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.RelationshipTuples.Create(ctx, models.RelationshipTupleCreate{Subject: target.ID(), Relation: "parent", Object: id, Tenant: ptr(to)}) + return err + }); err != nil { + return nil, fmt.Errorf("linking %s to %s: %w", id, target.ID(), err) + } + truck.Tenant, truck.Parent, truck.Attributes = to, target.ID(), attrs + a.assignments = slices.DeleteFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.ResourceInstance == id }) + r.untilData(fmt.Sprintf("%s in %s", truck.Name, model.OrgName(to)), "resource_instances/"+id, func(d any) bool { return field(d, "tenant") == to }) + r.holdLinks(map[string]string{id: target.ID()}, 5*time.Second, 30*time.Second) + // The old organization's grants did not follow the truck: decided by the PDP, acting for each side. + for _, u := range grantees { + for _, o := range []string{from, to} { + question := fmt.Sprintf("After the transfer: can %s still view %s, acting for %s?", model.FullName(u), truck.Name, model.OrgName(o)) + if _, err := r.checkOne(question, u, "view", id, o); err != nil { + return nil, err + } + } + } + names := make([]string, len(grantees)) + for n, u := range grantees { + names[n] = model.FullName(u) + } + removed := "none" + if len(names) > 0 { + removed = strings.Join(names, ", ") + } + message = fmt.Sprintf("Transferred %s to %s, %s. Grants from %s were removed with it (%s); nothing followed the truck.", truck.Name, model.OrgName(to), target.Name, model.OrgName(from), removed) + a.record(user, message) + return r.done(message, obj{"redirect": "#/"}), nil + + default: + return fail("Unknown action.") + } + a.record(user, message) + return r.done(message, nil), nil +} + +// someoneOn is a person with a role on the instance, other than except; the site manager if there is one. +func (a *App) someoneOn(id, except string) (model.RoleAssignment, bool) { + var found model.RoleAssignment + for _, ra := range a.assignments { + if ra.ResourceInstance != id || ra.User == except { + continue + } + if ra.Role == "site_manager" { + return ra, true + } + if found.User == "" { + found = ra + } + } + return found, found.User != "" +} + +// withTenantRole lists the people holding an org-level role in an organization. +func (a *App) withTenantRole(role, tenant string) []string { + var users []string + for _, ra := range a.assignments { + if ra.ResourceInstance == "" && ra.Role == role && ra.Tenant == tenant { + users = append(users, ra.User) + } + } + return users +} + +// writeAssignment assigns or unassigns a resource role through the SDK and keeps the app's copy in step. +func (a *App) writeAssignment(ra model.RoleAssignment, assign bool) error { + if assign { + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.Users.AssignResourceRole(ctx, ra.User, ra.Role, ra.Tenant, ra.ResourceInstance) + return err + }, 409); err != nil { + return fmt.Errorf("assigning %s the %s role on %s: %w", ra.User, ra.Role, ra.ResourceInstance, err) + } + a.assignments = append(a.assignments, ra) + return nil + } + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.Users.UnassignResourceRole(ctx, ra.User, ra.Role, ra.Tenant, ra.ResourceInstance) + return err + }, 404); err != nil { + return fmt.Errorf("unassigning the %s role on %s from %s: %w", ra.Role, ra.ResourceInstance, ra.User, err) + } + a.assignments = slices.DeleteFunc(a.assignments, func(x model.RoleAssignment) bool { + return x.User == ra.User && x.Role == ra.Role && x.ResourceInstance == ra.ResourceInstance + }) + return nil +} + +// field reads a key from decoded JSON, nil when absent. +func field(data any, key string) any { + m, _ := data.(map[string]any) + return m[key] +} diff --git a/fleet-ops-demo/internal/server/actions_test.go b/fleet-ops-demo/internal/server/actions_test.go new file mode 100644 index 0000000..ad94fea --- /dev/null +++ b/fleet-ops-demo/internal/server/actions_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "strings" + "sync/atomic" + "testing" +) + +// An action reports success only when the PDP shows the write. When the PDP lags, or its data +// can't be read, the answer says so instead of claiming the effect. +func TestActionsReportWhetherThePDPCaughtUp(t *testing.T) { + shortWaits(t) + grant := `{"user":"rachel","org":"acme","action":"grant","partner":"pat"}` + decommission := `{"user":"sam","org":"acme","action":"decommission"}` + stored := func(state string) func(string) (int, string) { + return func(path string) (int, string) { + if path == "/v1/data/resource_instances/asset:acme-dal-01" { + return 200, `{"result":{"tenant":"acme","attributes":{"lifecycle_state":"` + state + `"}}}` + } + return 200, `{}` + } + } + broken := func(string) (int, string) { return 500, `{"code":"internal_error"}` } + + for name, tc := range map[string]struct { + path, body string + fake func(granted *atomic.Bool) *fake + ok bool + says string // part of the message (ok) or of the error (not ok) + }{ + "grant, the decision flips": { + path: "/api/trucks/acme-hou-01/act", body: grant, ok: true, + fake: func(granted *atomic.Bool) *fake { + return &fake{decide: func(user, action, resource, tenant string) bool { + return user != "pat" || granted.Load() + }} + }, + says: "Pat Morgan can now service Truck HOU-01, and only this truck, acting for Acme Freight.", + }, + "grant, the decision never flips": { + path: "/api/trucks/acme-hou-01/act", body: grant, + fake: func(*atomic.Bool) *fake { + return &fake{decide: func(user, _, _, _ string) bool { return user != "pat" }} + }, + says: "Permit has the change, but the PDP doesn't confirm it: after 300ms it still denies Pat Morgan view on Truck HOU-01.", + }, + "decommission, the PDP's data shows it": { + path: "/api/trucks/acme-dal-01/act", body: decommission, ok: true, + fake: func(*atomic.Bool) *fake { return &fake{opa: stored("decommissioned")} }, + says: "Truck DAL-01 is decommissioned: its lifecycle_state attribute in Permit changed.", + }, + "decommission, the PDP's data never shows it": { + path: "/api/trucks/acme-dal-01/act", body: decommission, + fake: func(*atomic.Bool) *fake { return &fake{opa: stored("active")} }, + says: "after 300ms its data doesn't show Truck DAL-01's lifecycle_state decommissioned", + }, + "decommission, the PDP's data can't be read": { + path: "/api/trucks/acme-dal-01/act", body: decommission, + fake: func(*atomic.Bool) *fake { return &fake{opa: broken} }, + says: "couldn't read its data to confirm Truck DAL-01's lifecycle_state decommissioned (reading the PDP's data: GET /v1/data/resource_instances/asset:acme-dal-01: 500 Internal Server Error)", + }, + "transfer, the PDP's data can't be read": { + path: "/api/trucks/acme-dal-01/act", body: `{"user":"ava","org":"acme","action":"transfer","site":"lonestar-fortworth"}`, + fake: func(*atomic.Bool) *fake { return &fake{opa: broken} }, + says: "couldn't read its data to confirm Truck DAL-01 in Lone Star Logistics", + }, + } { + t.Run(name, func(t *testing.T) { + var granted atomic.Bool + f := tc.fake(&granted) + _, h := newTestApp(t, f) + if strings.Contains(tc.body, `"grant"`) { + // Permit takes the assignment: the PDP allows Pat from then on (when it catches up). + inner := f.decide + f.decide = func(user, action, resource, tenant string) bool { + for _, w := range f.written() { + if strings.Contains(w, "/users/pat/roles") { + granted.Store(true) + } + } + return inner(user, action, resource, tenant) + } + } + code, out := post(t, h, tc.path, tc.body) + if code != 200 { + t.Fatalf("status %d: %v", code, out) + } + if ok, _ := out["ok"].(bool); ok != tc.ok { + t.Fatalf("ok = %v, want %v: %v", ok, tc.ok, out) + } + text, _ := out["message"].(string) + if !tc.ok { + text, _ = out["error"].(string) + if _, claims := out["message"]; claims { + t.Errorf("a lagging write still carries the success message %q", out["message"]) + } + } + if !strings.Contains(text, tc.says) { + t.Errorf("got %q, want it to contain %q", text, tc.says) + } + if len(f.written()) == 0 { + t.Error("nothing was written to Permit") + } + }) + } +} + +// After a transfer the page moves on whatever the PDP shows: the truck has left the organization. +func TestTransferRedirectsEvenWhenThePDPLags(t *testing.T) { + shortWaits(t) + _, h := newTestApp(t, &fake{opa: func(string) (int, string) { return 500, `{}` }}) + _, out := post(t, h, "/api/trucks/acme-dal-01/act", `{"user":"ava","org":"acme","action":"transfer","site":"lonestar-fortworth"}`) + if out["redirect"] != "#/" { + t.Errorf("redirect %v, want #/: %v", out["redirect"], out) + } +} diff --git a/fleet-ops-demo/internal/server/harness_test.go b/fleet-ops-demo/internal/server/harness_test.go new file mode 100644 index 0000000..84fcdd4 --- /dev/null +++ b/fleet-ops-demo/internal/server/harness_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "encoding/json" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + "fleetops/internal/model" + "fleetops/internal/pdp" + "fleetops/internal/settings" +) + +// fake is one server playing the PDP (/allowed, /allowed/bulk, /healthy), its OPA (/v1/...) and the +// Permit API (/v2/...), so the app's handlers run end to end without Permit. +type fake struct { + mu sync.Mutex + // decide answers a check; nil allows everything. + decide func(user, action, resource, tenant string) bool + // opa answers an OPA read (path and status, body); nil answers 200 with no result. + opa func(path string) (int, string) + // users is Permit's copy of people's attributes, served on GET .../users/{key}. + users map[string]map[string]any + // writes lists the API calls that change something: "METHOD path body". + writes []string +} + +type checkBody struct { + User struct{ Key string } + Action string + Resource struct{ Type, Key, Tenant string } +} + +func (f *fake) allow(c checkBody) bool { + f.mu.Lock() + decide := f.decide + f.mu.Unlock() + return decide == nil || decide(c.User.Key, c.Action, c.Resource.Type+":"+c.Resource.Key, c.Resource.Tenant) +} + +func answer(allow bool) string { + b, _ := json.Marshal(map[string]any{"allow": allow, "debug": map[string]any{"rbac": map[string]any{"allow": allow}}}) + return string(b) +} + +func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/allowed": + var c checkBody + _ = json.Unmarshal(body, &c) + _, _ = io.WriteString(w, answer(f.allow(c))) + case r.URL.Path == "/allowed/bulk": + var checks []checkBody + _ = json.Unmarshal(body, &checks) + items := make([]string, len(checks)) + for n, c := range checks { + items[n] = answer(f.allow(c)) + } + _, _ = io.WriteString(w, `{"allow":[`+strings.Join(items, ",")+`]}`) + case r.URL.Path == "/healthy": + _, _ = io.WriteString(w, `{}`) + case strings.HasPrefix(r.URL.Path, "/v1/"): + status, out := 200, `{}` + if f.opa != nil { + status, out = f.opa(r.URL.Path) + } + w.WriteHeader(status) + _, _ = io.WriteString(w, out) + case r.URL.Path == "/v2/api-key/scope": + _, _ = io.WriteString(w, `{"organization_id":"o","project_id":"p","environment_id":"e"}`) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v2/facts/p/e/users/"): + key := strings.TrimPrefix(r.URL.Path, "/v2/facts/p/e/users/") + f.mu.Lock() + attrs, ok := f.users[key] + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{}`) + return + } + out, _ := json.Marshal(map[string]any{"key": key, "id": "id-" + key, "attributes": attrs}) + _, _ = w.Write(out) + default: + if r.Method != http.MethodGet { + f.mu.Lock() + f.writes = append(f.writes, r.Method+" "+r.URL.Path+" "+string(body)) + f.mu.Unlock() + } + // Enough for every SDK read model to decode; RoleAssignmentRead checks its required fields. + _, _ = io.WriteString(w, `{"id":"1","key":"k","user":"u","role":"r","tenant":"t","user_id":"1","role_id":"1","tenant_id":"1",`+ + `"organization_id":"o","project_id":"p","environment_id":"e","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}`) + } +} + +func (f *fake) written() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.writes...) +} + +// newTestApp is the app with the seed loaded, as `make reset` leaves Permit, talking to f. +func newTestApp(t *testing.T, f *fake) (*App, http.Handler) { + t.Helper() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + s := settings.Settings{APIKey: "permit_key_test", APIURL: srv.URL, PDPURL: srv.URL, OPAURL: srv.URL} + a := New(pdp.New(s, settings.Client(s))) + for _, tenant := range model.Tenants { + a.tenants[tenant.Key] = true + } + for _, seeded := range model.Instances { + i := seeded + i.Attributes = maps.Clone(seeded.Attributes) + if i.Attributes == nil { + i.Attributes = map[string]any{} + } + a.instances[i.ID()] = &i + } + a.assignments = append(a.assignments, model.RoleAssignments...) + for _, u := range model.Users { + certs := make([]any, len(u.Certifications)) + for n, c := range u.Certifications { + certs[n] = c + } + a.userAttrs[u.Key] = map[string]any{"home_tenant": u.HomeTenant, "certifications": certs} + } + for team, keys := range model.TeamMembers { + a.members[team] = append([]string(nil), keys...) + } + return a, a.Handler(fstest.MapFS{}, "p", "e") +} + +// shortWaits makes the post-write waits short for the test. +func shortWaits(t *testing.T) { + t.Helper() + decision, data := decisionWait, dataWait + decisionWait, dataWait = 300*time.Millisecond, 300*time.Millisecond + t.Cleanup(func() { decisionWait, dataWait = decision, data }) +} + +// post sends an action the way the page does and decodes the answer. +func post(t *testing.T, h http.Handler, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://localhost:3000"+path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("POST %s: %d %q is not JSON", path, rec.Code, rec.Body.String()) + } + return rec.Code, out +} + +// get loads a page the way the page does and decodes the answer. +func get(t *testing.T, h http.Handler, path string) (int, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://localhost:3000"+path, nil)) + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("GET %s: %d %q is not JSON", path, rec.Code, rec.Body.String()) + } + return rec.Code, out +} diff --git a/fleet-ops-demo/internal/server/http.go b/fleet-ops-demo/internal/server/http.go new file mode 100644 index 0000000..d92dbea --- /dev/null +++ b/fleet-ops-demo/internal/server/http.go @@ -0,0 +1,152 @@ +package server + +import ( + "encoding/json" + "fmt" + "io/fs" + "log" + "mime" + "net" + "net/http" + + "fleetops/internal/model" +) + +// Handler serves the API and the page (files from web), to this machine's browser only: see +// localOnly. +func (a *App) Handler(web fs.FS, project, env string) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/meta", func(w http.ResponseWriter, _ *http.Request) { + type person struct { + Key string `json:"key"` + Name string `json:"name"` + Title string `json:"title"` + Org string `json:"org"` // employer, for grouping the sign-in menu + } + users := make([]person, len(model.Users)) + for n, u := range model.Users { + users[n] = person{Key: u.Key, Name: model.FullName(u.Key), Title: u.Title, Org: model.OrgName(u.HomeTenant)} + } + writeJSON(w, http.StatusOK, obj{"users": users, "project": project, "env": env}) + }) + mux.HandleFunc("GET /api/orgs", func(w http.ResponseWriter, req *http.Request) { + a.mu.RLock() + defer a.mu.RUnlock() + writeJSON(w, http.StatusOK, obj{"orgs": a.memberships(req.URL.Query().Get("user"))}) + }) + mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, a.pdp.Status()) + }) + mux.HandleFunc("GET /api/fleet", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.fleet(user, org) + })) + mux.HandleFunc("GET /api/trucks/{key}", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.truckPage(user, org, req.PathValue("key")) + })) + mux.HandleFunc("GET /api/sites/{key}", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.sitePage(user, org, req.PathValue("key")) + })) + mux.HandleFunc("POST /api/trucks/{key}/act", a.write(func(req *http.Request, body actBody) (obj, error) { + return a.truckAction(body, req.PathValue("key")) + })) + mux.HandleFunc("GET /api/people", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.peoplePage(user, org) + })) + mux.HandleFunc("POST /api/people/{key}/act", a.write(func(req *http.Request, body actBody) (obj, error) { + return a.personAction(body, req.PathValue("key")) + })) + mux.HandleFunc("GET /api/access", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.accessPage(user, org) + })) + mux.HandleFunc("POST /api/access/act", a.write(func(req *http.Request, body actBody) (obj, error) { + return a.accessAction(body) + })) + mux.HandleFunc("GET /api/organizations", a.read(func(req *http.Request, user, org string) (obj, error) { + return a.orgsPage(user) + })) + mux.HandleFunc("POST /api/organizations/{key}/act", a.write(func(req *http.Request, body actBody) (obj, error) { + return a.orgAction(body, req.PathValue("key")) + })) + mux.Handle("/", http.FileServerFS(web)) + return localOnly(mux) +} + +// localOnly keeps other web sites out. The server listens on 127.0.0.1 and has no sign-in, so any +// page open in the presenter's browser could otherwise drive it: a cross-site POST (a text/plain one +// needs no CORS preflight) is refused by http.CrossOriginProtection, and a page that rebinds its own +// host name to 127.0.0.1 to read the answers is refused by the Host check. +func localOnly(next http.Handler) http.Handler { + crossOrigin := http.NewCrossOriginProtection().Handler(next) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + host, _, err := net.SplitHostPort(req.Host) + if err != nil { + host = req.Host + } + if host != "localhost" && host != "127.0.0.1" { + http.Error(w, "Open Fleet Ops at http://localhost.", http.StatusForbidden) + return + } + crossOrigin.ServeHTTP(w, req) + }) +} + +// recovered turns a panic in a handler into a 502 with the error, so the page shows it. +func recovered(w http.ResponseWriter, req *http.Request) { + if p := recover(); p != nil { + log.Printf("%s %s: panic: %v", req.Method, req.URL.Path, p) + writeJSON(w, http.StatusBadGateway, obj{"error": fmt.Sprintf("internal error: %v", p)}) + } +} + +// read serves a page: it reads the app's state while it asks the PDP. +func (a *App) read(page func(req *http.Request, user, org string) (obj, error)) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + defer recovered(w, req) + a.mu.RLock() + defer a.mu.RUnlock() + q := req.URL.Query() + respond(w, req)(page(req, q.Get("user"), q.Get("org"))) + } +} + +// write serves an action: it holds the app's state for the whole action, checks and writes included. +func (a *App) write(action func(req *http.Request, body actBody) (obj, error)) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + defer recovered(w, req) + if ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type")); ct != "application/json" { + writeJSON(w, http.StatusUnsupportedMediaType, obj{"error": "The request body must be JSON (Content-Type: application/json)."}) + return + } + var body actBody + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, obj{"error": "The request body must be JSON."}) + return + } + a.mu.Lock() + defer a.mu.Unlock() + respond(w, req)(action(req, body)) + } +} + +// respond writes the result, or the error as {"error": ...} with status 502, as the page expects. +func respond(w http.ResponseWriter, req *http.Request) func(obj, error) { + return func(result obj, err error) { + if err != nil { + log.Printf("%s %s: %v", req.Method, req.URL.Path, err) + writeJSON(w, http.StatusBadGateway, obj{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + log.Printf("writing JSON: %v", err) + } +} diff --git a/fleet-ops-demo/internal/server/http_test.go b/fleet-ops-demo/internal/server/http_test.go new file mode 100644 index 0000000..91f905c --- /dev/null +++ b/fleet-ops-demo/internal/server/http_test.go @@ -0,0 +1,63 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Another web site open in the presenter's browser must not drive the app: no cross-site write, and +// no reading answers through a host name rebound to 127.0.0.1. +func TestOnlyThisMachinesPageIsServed(t *testing.T) { + dispatch := `{"user":"sam","org":"acme","action":"dispatch","lane":"from another site"}` + for name, tc := range map[string]struct { + method, url, body string + header map[string]string + want int + }{ + "the page's own POST": { + "POST", "http://localhost:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "application/json", "Origin": "http://localhost:3000", "Sec-Fetch-Site": "same-origin"}, 200, + }, + "a POST without browser headers (curl)": { + "POST", "http://127.0.0.1:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "application/json"}, 200, + }, + "a page load at 127.0.0.1": {"GET", "http://127.0.0.1:3000/api/meta", "", nil, 200}, + "a cross-site text/plain POST (no preflight)": { + "POST", "http://localhost:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "text/plain;charset=UTF-8", "Origin": "https://attacker.example", "Sec-Fetch-Site": "cross-site"}, 403, + }, + "a cross-site JSON POST": { + "POST", "http://localhost:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "application/json", "Origin": "https://attacker.example"}, 403, + }, + "a rebound host name, reading": {"GET", "http://attacker.example:3000/api/people?user=olivia&org=acme", "", nil, 403}, + "a rebound host name, writing": { + "POST", "http://attacker.example:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "application/json", "Origin": "http://attacker.example:3000", "Sec-Fetch-Site": "same-origin"}, 403, + }, + "the page's own POST, not JSON": { + "POST", "http://localhost:3000/api/trucks/acme-dal-01/act", dispatch, + map[string]string{"Content-Type": "text/plain", "Sec-Fetch-Site": "same-origin"}, 415, + }, + } { + t.Run(name, func(t *testing.T) { + a, h := newTestApp(t, &fake{}) + req := httptest.NewRequest(tc.method, tc.url, strings.NewReader(tc.body)) + for k, v := range tc.header { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("status %d, want %d: %s", rec.Code, tc.want, rec.Body.String()) + } + dispatched := len(a.history["asset:acme-dal-01"]) > 0 + if wrote := tc.method == http.MethodPost && tc.want == 200; dispatched != wrote { + t.Errorf("dispatched = %v, want %v", dispatched, wrote) + } + }) + } +} diff --git a/fleet-ops-demo/internal/server/log.go b/fleet-ops-demo/internal/server/log.go new file mode 100644 index 0000000..cff9ac6 --- /dev/null +++ b/fleet-ops-demo/internal/server/log.go @@ -0,0 +1,239 @@ +package server + +import ( + "errors" + "fmt" + "maps" + "strings" + "time" + + "fleetops/internal/explain" + "fleetops/internal/model" + "fleetops/internal/pdp" +) + +// Call is one check in a log entry, with the PDP's answer and its explanation. +type Call struct { + User string `json:"user"` + Action string `json:"action"` + Resource pdp.Resource `json:"resource"` + Allow bool `json:"allow"` + Code string `json:"code"` + explain.Explanation +} + +// Entry is one line of the PDP log: a single check or a bulk check. +type Entry struct { + ID int64 `json:"id"` + At string `json:"at"` + Purpose string `json:"purpose"` + Kind string `json:"kind"` // check or bulk + MS float64 `json:"ms"` + Calls []Call `json:"calls"` + Code string `json:"code"` + Debug map[string]any `json:"debug"` // the PDP's raw debug output, single checks only +} + +// request collects the checks one HTTP request makes, for the log, and what the PDP had not caught +// up with after the request's writes. +type request struct { + app *App + log []Entry + lag []string +} + +func (a *App) newRequest() *request { return &request{app: a, log: []Entry{}} } + +func millis(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } + +// record logs checks with their answers. debugs holds the PDP's debug output for each check; when +// it couldn't be read (debugErr), each check is logged with its verdict only. +func (r *request) record(purpose, kind string, checks []pdp.Check, results []bool, elapsed time.Duration, debugs []map[string]any, debugErr error) Entry { + calls := make([]Call, len(checks)) + for n, c := range checks { + var debug map[string]any + if debugErr == nil { + debug = debugs[n] + } + explanation := explain.Explain(results[n], debug, c, r.app.nameOf) + if debugErr != nil { + explanation = explain.Unavailable(results[n], c, r.app.nameOf, "the PDP's debug output couldn't be read: "+debugErr.Error()) + } + calls[n] = Call{ + User: c.User, Action: c.Action, Resource: c.Resource, Allow: results[n], Code: pdp.Code(c), + Explanation: explanation, + } + } + e := Entry{ + ID: r.app.seq.Add(1), + At: time.Now().UTC().Format(time.RFC3339Nano), + Purpose: purpose, + Kind: kind, + MS: millis(elapsed), + Calls: calls, + Code: pdp.BulkCode(len(checks)), + } + if kind == "check" { + e.Code = calls[0].Code + if debugErr == nil { + e.Debug = debugs[0] + } + } + r.log = append(r.log, e) + return e +} + +// last is the newest single check in the log. +func (r *request) last() Call { return r.log[len(r.log)-1].Calls[0] } + +// refuse answers an action the PDP denied, with the PDP's reason, or passes on the error when the +// check itself failed. +func (r *request) refuse(err error) (obj, error) { + if err != nil { + return nil, err + } + return obj{"denied": true, "message": r.last().Reason, "log": r.log}, nil +} + +// done answers a write Permit accepted, with the success message and any extra fields (a redirect). +// When the PDP had not caught up with the write in time, the answer says so in place of the success +// message, so the page never claims an effect the PDP doesn't show yet. +func (r *request) done(message string, extra obj) obj { + out := obj{"log": r.log} + maps.Copy(out, extra) + if len(r.lag) > 0 { + out["error"] = fmt.Sprintf("Permit has the change, but the PDP doesn't confirm it: %s. Its decisions may still use the old data; reload in a moment, or run `make verify`.", strings.Join(r.lag, "; ")) + return out + } + out["ok"], out["message"] = true, message + return out +} + +// lagged notes what the PDP had not caught up with, or couldn't be read to confirm, for done. +func (r *request) lagged(err error) { + if behind, ok := errors.AsType[*pdp.NotCaughtUpError](err); ok { + r.lag = append(r.lag, behind.Detail) + return + } + r.lag = append(r.lag, err.Error()) +} + +// checkOne asks the PDP through permitClient.Check and logs the answer with its reason. +func (r *request) checkOne(purpose, user, action, resource, org string) (bool, error) { + c := pdp.NewCheck(user, action, resource, org) + started := time.Now() + allow, err := r.app.pdp.Check(c) + if err != nil { + return false, fmt.Errorf("permitClient.Check: %w", err) + } + elapsed := time.Since(started) + debug, debugErr := r.app.pdp.Debug(c) + r.record(purpose, "check", []pdp.Check{c}, []bool{allow}, elapsed, []map[string]any{debug}, debugErr) + return allow, nil +} + +// checkMany asks the PDP through permitClient.BulkCheck and logs the answers with their reasons. +func (r *request) checkMany(purpose string, checks []pdp.Check) ([]bool, error) { + if len(checks) == 0 { + return []bool{}, nil + } + started := time.Now() + results, err := r.app.pdp.BulkCheck(checks) + if err != nil { + return nil, fmt.Errorf("permitClient.BulkCheck: %w", err) + } + if len(results) != len(checks) { + return nil, fmt.Errorf("permitClient.BulkCheck: %d answers for %d checks", len(results), len(checks)) + } + elapsed := time.Since(started) + debugs, debugErr := r.app.pdp.DebugMany(checks) + r.record(purpose, "bulk", checks, results, elapsed, debugs, debugErr) + return results, nil +} + +// decisionWait bounds how long untilDecision polls for the answer to flip after a write. +var decisionWait = 10 * time.Second + +// untilDecision runs after a write: it polls the PDP until the answer flips (at most decisionWait), +// then logs that final check with the delay. When the answer never flips, it notes that for done. +func (r *request) untilDecision(purpose, user, action, resource, org string, want bool) error { + c := pdp.NewCheck(user, action, resource, org) + started := time.Now() + var allow bool + var elapsed time.Duration + for { + t := time.Now() + var err error + if allow, err = r.app.pdp.Check(c); err != nil { + return fmt.Errorf("permitClient.Check: %w", err) + } + elapsed = time.Since(t) + if allow == want || time.Since(started) > decisionWait { + break + } + time.Sleep(150 * time.Millisecond) + } + waited := time.Since(started) + debug, debugErr := r.app.pdp.Debug(c) + r.record(fmt.Sprintf("%s (%d ms after the write)", purpose, waited.Milliseconds()), "check", []pdp.Check{c}, []bool{allow}, elapsed, []map[string]any{debug}, debugErr) + if allow != want { + verdict := "denies" + if allow { + verdict = "allows" + } + r.lagged(fmt.Errorf("after %s it still %s %s %s on %s", decisionWait, verdict, model.FullName(user), action, r.app.nameOf(resource))) + } + return nil +} + +// platform asks whether the person is platform staff, for platform-only actions (tier 3, transfers, +// deleting or merging an organization): org admin rights (manage_users) on the platform operator's +// own organization, acting for it. +func (r *request) platform(purpose, user string) (bool, error) { + return r.checkOne(purpose, user, "manage_users", model.PlatformOrg, model.PlatformTenant) +} + +// platformCheck is the same check, for a bulk. +func platformCheck(user string) pdp.Check { + return pdp.NewCheck(user, "manage_users", model.PlatformOrg, model.PlatformTenant) +} + +// dataWait bounds how long untilData waits for the PDP's data to show a write. +var dataWait = 10 * time.Second + +// untilData runs after a write: it waits until the PDP's loaded data at path shows it (at most +// dataWait). what names the change, for the note done shows when the PDP hasn't caught up or its +// data couldn't be read. +func (r *request) untilData(what, path string, ready func(data any) bool) { + started := time.Now() + for { + data, err := r.app.pdp.OPAData(path) + if err != nil { + r.lagged(fmt.Errorf("couldn't read its data to confirm %s (%w)", what, err)) + return + } + if ready(data) { + return + } + if time.Since(started) > dataWait { + r.lagged(fmt.Errorf("after %s its data doesn't show %s", dataWait, what)) + return + } + time.Sleep(150 * time.Millisecond) + } +} + +// holdLinks waits until the PDP has the parent links and keeps them (pdp.Settle, then pdp.Keep), and +// notes for done what it had not caught up with. +func (r *request) holdLinks(links map[string]string, stable, limit time.Duration) { + if err := r.app.pdp.Settle(links); err != nil { + if _, behind := errors.AsType[*pdp.NotCaughtUpError](err); !behind { + r.lagged(fmt.Errorf("couldn't read its data to confirm the parent links (%w)", err)) + return + } + } + // Settle can see the links before a late deletion takes them again: Keep has the last word. + if _, err := r.app.pdp.Keep(links, stable, limit); err != nil { + r.lagged(err) + } +} diff --git a/fleet-ops-demo/internal/server/orgs.go b/fleet-ops-demo/internal/server/orgs.go new file mode 100644 index 0000000..e68517a --- /dev/null +++ b/fleet-ops-demo/internal/server/orgs.go @@ -0,0 +1,323 @@ +package server + +import ( + "context" + "fmt" + "log" + "maps" + "slices" + "time" + + "github.com/permitio/permit-golang/pkg/models" + + "fleetops/internal/model" + "fleetops/internal/permitapi" +) + +// orgsPage is the platform operator's console: the customer organizations, which platform staff may delete +// (scenario 15) or merge into another as a sub-organization (scenario 16). +func (a *App) orgsPage(user string) (obj, error) { + r := a.newRequest() + allowed, err := r.platform(fmt.Sprintf("Organizations: is %s platform staff?", model.UserName(user)), user) + if err != nil { + return nil, err + } + if !allowed { + return obj{"denied": true, "reason": r.last().Reason, "log": r.log}, nil + } + rows, targets := []obj{}, []named{} + for _, t := range model.Tenants { + if !a.tenants[t.Key] || t.Platform { + continue + } + counts := a.counts(t.Key) + rows = append(rows, obj{ + "key": t.Key, "name": t.Name, "description": t.Description, "partner": t.Partner, + "trucks": counts["trucks"], "resources": counts["resources"], "assignments": counts["assignments"], + }) + if !t.Partner { + targets = append(targets, named{Key: t.Key, Name: t.Name}) + } + } + return obj{"orgs": rows, "mergeTargets": targets, "log": r.log}, nil +} + +// counts tallies what an organization holds in Permit, from the app's copy. +func (a *App) counts(tenant string) map[string]int { + c := map[string]int{} + for _, i := range a.instances { + if i.Tenant == tenant { + c["resources"]++ + if i.Type == "asset" { + c["trucks"]++ + } + } + } + for _, ra := range a.assignments { + if ra.Tenant == tenant { + c["assignments"]++ + } + } + return c +} + +// orgAction deletes an organization (scenario 15) or merges it into another as a sub-organization, +// keeping its role assignments (scenario 16). Platform staff only. +func (a *App) orgAction(body actBody, key string) (obj, error) { + r := a.newRequest() + user := body.User + fail := func(message string) (obj, error) { return obj{"error": message, "log": r.log}, nil } + t, ok := model.TenantOf(key) + if !ok || !a.tenants[key] || t.Platform { + return fail("There is no such customer organization.") + } + // Someone with a role there (not platform staff), and one of its trucks, to show what changes. + var probeUser string + for _, ra := range a.assignments { + if ra.Tenant == key && !a.platformStaff(ra.User) { + probeUser = ra.User + if ra.Role == "org_admin" { + break + } + } + } + var probe *model.Instance + for _, truck := range a.fleetOf(key) { + if truck.Tenant == key { + probe = truck + break + } + } + + switch body.Action { + case "delete": + if ok, err := r.platform(fmt.Sprintf("Delete %s: platform staff only, enforce before writing", t.Name), user); !ok { + return r.refuse(err) + } + if probeUser != "" && probe != nil { + if _, err := r.checkOne(fmt.Sprintf("Before: can %s view %s?", model.FullName(probeUser), probe.Name), probeUser, "view", probe.ID(), key); err != nil { + return nil, err + } + } + before := a.counts(key) + if err := permitapi.Call(func(ctx context.Context) error { return a.api.Tenants.Delete(ctx, key) }, 404); err != nil { + return nil, fmt.Errorf("deleting tenant %s: %w", key, err) + } + a.forget(key) + if probeUser != "" && probe != nil { + if err := r.untilDecision(fmt.Sprintf("After: can %s view %s?", model.FullName(probeUser), probe.Name), probeUser, "view", probe.ID(), key, false); err != nil { + return nil, err + } + } + instances, tuples, assignments, err := a.leftIn(key) + if err != nil { + return nil, err + } + message := fmt.Sprintf("Deleted %s with its %d resources and %d role assignments. Left behind in Permit: %d resource instances, %d relationships, %d role assignments.", + t.Name, before["resources"], before["assignments"], instances, tuples, assignments) + a.record(user, "Deleted "+t.Name) + return r.done(message, obj{"redirect": "#/organizations"}), nil + + case "merge": + target, ok := model.TenantOf(body.Target) + if !ok || !a.tenants[target.Key] || target.Key == key || target.Partner || target.Platform { + return fail("Pick a customer organization to merge into.") + } + if ok, err := r.platform(fmt.Sprintf("Merge %s into %s: platform staff only, enforce before writing", t.Name, target.Name), user); !ok { + return r.refuse(err) + } + if probeUser != "" && probe != nil { + if _, err := r.checkOne(fmt.Sprintf("Before: can %s view %s, acting for %s?", model.FullName(probeUser), probe.Name, target.Name), probeUser, "view", probe.ID(), target.Key); err != nil { + return nil, err + } + } + if err := a.merge(r, key, target.Key); err != nil { + return nil, err + } + if probeUser != "" && probe != nil { + if err := r.untilDecision(fmt.Sprintf("After: can %s view %s, acting for %s?", model.FullName(probeUser), probe.Name, target.Name), probeUser, "view", probe.ID(), target.Key, true); err != nil { + return nil, err + } + // The target's own org admin reaches the merged tree through the new sub-organization link. + for _, ra := range a.assignments { + if ra.Tenant == target.Key && ra.Role == "org_admin" && ra.ResourceInstance == "org:"+target.Key && !a.platformStaff(ra.User) { + question := fmt.Sprintf("Can %s, org admin of %s, view %s now?", model.FullName(ra.User), target.Name, probe.Name) + if _, err := r.checkOne(question, ra.User, "view", probe.ID(), target.Key); err != nil { + return nil, err + } + break + } + } + } + message := fmt.Sprintf("Merged %s into %s as a sub-organization: its tree now hangs under %s, and its people keep their roles there.", t.Name, target.Name, target.Name) + a.record(user, fmt.Sprintf("Merged %s into %s", t.Name, target.Name)) + return r.done(message, obj{"redirect": "#/organizations"}), nil + } + return fail("Unknown action.") +} + +// forget drops a deleted organization from the app's copy of Permit. +func (a *App) forget(tenant string) { + delete(a.tenants, tenant) + for id, i := range a.instances { + if i.Tenant == tenant { + delete(a.instances, id) + delete(a.members, id) + } + } + a.assignments = slices.DeleteFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.Tenant == tenant }) +} + +// leftIn counts what Permit still holds for a deleted organization. +func (a *App) leftIn(tenant string) (instances, tuples, assignments int, err error) { + rows, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.ResourceInstanceRead, error) { + return permitapi.Deref(a.api.ResourceInstances.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return 0, 0, 0, fmt.Errorf("listing resource instances: %w", err) + } + for _, i := range rows { + if i.Tenant != nil && *i.Tenant == tenant { + instances++ + } + } + links, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RelationshipTupleRead, error) { + return permitapi.Deref(a.api.RelationshipTuples.List(ctx, page, perPage, "", "", "", "")) + }) + if err != nil { + return 0, 0, 0, fmt.Errorf("listing relationships: %w", err) + } + for _, t := range links { + if t.Tenant == tenant { + tuples++ + } + } + roles, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RoleAssignmentRead, error) { + return permitapi.Deref(a.api.RoleAssignments.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return 0, 0, 0, fmt.Errorf("listing role assignments: %w", err) + } + for _, ra := range roles { + if ra.Tenant == tenant { + assignments++ + } + } + return +} + +// merge moves an organization's tree into another organization, under a sub-organization link, and +// gives its people the same roles there. Instances can't change tenant, so the old organization is +// deleted (taking its data with it) and everything is created again in the target. What the PDP had +// not caught up with at the end is noted on r. +func (a *App) merge(r *request, from, to string) error { + var moving []*model.Instance + for _, i := range a.instances { + if i.Tenant == from { + moving = append(moving, i) + } + } + tuples, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RelationshipTupleRead, error) { + return permitapi.Deref(a.api.RelationshipTuples.List(ctx, page, perPage, from, "", "", "")) + }) + if err != nil { + return fmt.Errorf("listing the relationships of %s: %w", from, err) + } + var roles []model.RoleAssignment + for _, ra := range a.assignments { + if ra.Tenant == from && !a.platformStaff(ra.User) { + roles = append(roles, ra) + } + } + + ids := make([]string, len(moving)) + for n, i := range moving { + ids[n] = i.ID() + } + // Remove the organization's links first, and wait until the PDP has dropped them. Deleting the + // organization would remove them as well, but that cascade reaches the PDP late, after the new + // links with the same instance keys, and would remove those too. + if len(tuples) > 0 { + del := make([]models.RelationshipTupleDelete, len(tuples)) + for n, t := range tuples { + del[n] = *models.NewRelationshipTupleDelete(t.Subject, t.Relation, t.Object) + } + if err := permitapi.Call(func(ctx context.Context) error { + return a.api.RelationshipTuples.BulkDelete(ctx, *models.NewRelationshipTupleDeleteBulkOperation(del)) + }); err != nil { + return fmt.Errorf("removing the relationships of %s: %w", from, err) + } + // When the PDP still has them, a late deletion may strip the new links; holdLinks below repairs that. + if err := a.pdp.WaitUnlinked(ids, 20*time.Second); err != nil { + log.Printf("merge: deleting %s before the PDP dropped its links: %v", from, err) + } + } + if err := permitapi.Call(func(ctx context.Context) error { return a.api.Tenants.Delete(ctx, from) }, 404); err != nil { + return fmt.Errorf("deleting tenant %s: %w", from, err) + } + if err := a.pdp.WaitGone(ids, 20*time.Second); err != nil { + log.Printf("merge: creating %s's instances again before the PDP dropped them: %v", from, err) + } + for _, i := range moving { + attrs := maps.Clone(i.Attributes) + if i.Type == "asset" { + attrs["owner_org"], attrs["operator_org"] = to, to + } + body := models.ResourceInstanceCreate{Key: i.Key, Resource: i.Type, Tenant: ptr(to), Attributes: attrs} + if err := permitapi.CreateAgain(func(ctx context.Context) error { _, err := a.api.ResourceInstances.Create(ctx, body); return err }); err != nil { + return fmt.Errorf("creating %s in %s: %w", i.ID(), to, err) + } + i.Tenant, i.Attributes = to, attrs + } + links := []models.RelationshipTupleCreate{{Subject: "org:" + to, Relation: "parent", Object: "org:" + from, Tenant: ptr(to)}} + for _, t := range tuples { + links = append(links, models.RelationshipTupleCreate{Subject: t.Subject, Relation: t.Relation, Object: t.Object, Tenant: ptr(to)}) + } + if err := permitapi.Call(func(ctx context.Context) error { + return a.api.RelationshipTuples.BulkCreate(ctx, *models.NewRelationshipTupleCreateBulkOperation(links)) + }); err != nil { + return fmt.Errorf("linking the tree of %s under %s: %w", from, to, err) + } + a.get("org:" + from).Parent = "org:" + to + // Trucks the merged organization operated for another one are now operated by the target. + for _, i := range a.instances { + if i.Type == "asset" && str(i.Attributes["operator_org"]) == from { + attrs := maps.Clone(i.Attributes) + attrs["operator_org"] = to + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.ResourceInstances.Update(ctx, i.ID(), models.ResourceInstanceUpdate{Attributes: attrs}) + return err + }); err != nil { + return fmt.Errorf("updating the operator of %s: %w", i.ID(), err) + } + i.Attributes = attrs + } + } + if len(roles) > 0 { + assign := make([]models.RoleAssignmentCreate, len(roles)) + for n, ra := range roles { + assign[n] = *models.NewRoleAssignmentCreate(ra.Role, to, ra.User) + if ra.ResourceInstance != "" { + assign[n].ResourceInstance = ptr(ra.ResourceInstance) + } + } + if err := permitapi.Call(func(ctx context.Context) error { _, err := a.api.Roles.BulkAssignRole(ctx, assign); return err }); err != nil { + return fmt.Errorf("giving the people of %s their roles in %s: %w", from, to, err) + } + } + delete(a.tenants, from) + a.assignments = slices.DeleteFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.Tenant == from }) + for _, ra := range roles { + ra.Tenant = to + a.assignments = append(a.assignments, ra) + } + // Wait until the PDP has the tree in its new organization, every parent link included, and keeps it. + parents := map[string]string{} + for _, i := range moving { + if i.Parent != "" { + parents[i.ID()] = i.Parent + } + } + r.holdLinks(parents, 8*time.Second, time.Minute) + return nil +} diff --git a/fleet-ops-demo/internal/server/pages.go b/fleet-ops-demo/internal/server/pages.go new file mode 100644 index 0000000..28d6172 --- /dev/null +++ b/fleet-ops-demo/internal/server/pages.go @@ -0,0 +1,279 @@ +package server + +import ( + "fmt" + "slices" + "time" + + "fleetops/internal/model" + "fleetops/internal/pdp" +) + +type obj = map[string]any + +type truckSummary struct { + Key string `json:"key"` + Name string `json:"name"` + Site string `json:"site"` + SiteKey string `json:"siteKey"` + Region string `json:"region"` + Lifecycle any `json:"lifecycle"` + Owner string `json:"owner"` // owning organization + Operator any `json:"operator"` // operating organization when another one operates it, else null + Org string `json:"org"` // the organization the person acted for when the row was checked +} + +func (a *App) summary(truck *model.Instance) truckSummary { + site := a.siteOf(truck) + s := truckSummary{ + Key: truck.Key, + Name: truck.Name, + Site: site.Name, + SiteKey: site.Key, + Region: a.get(site.Parent).Name, + Lifecycle: truck.Attributes["lifecycle_state"], + Owner: model.OrgName(truck.Tenant), + } + if op := operatorOf(truck); op != truck.Tenant { + s.Operator = model.OrgName(op) + } + return s +} + +type named struct { + Key string `json:"key"` + Name string `json:"name"` +} + +// fleet lists the acting organization's trucks (owned or operated for another organization) and asks +// the PDP which ones the person may see. With org "*" it lists every organization the person can act +// for, each checked acting for that organization (scenario 8: a broker's shared trucks). +func (a *App) fleet(user, org string) (obj, error) { + r := a.newRequest() + orgs := []string{org} + if org == "*" { + orgs = nil + for _, m := range a.memberships(user) { + orgs = append(orgs, m.Key) + } + } + visible, total := []truckSummary{}, 0 + for _, o := range orgs { + trucks := a.fleetOf(o) + total += len(trucks) + if len(trucks) == 0 { + continue + } + checks := make([]pdp.Check, len(trucks)) + for n, t := range trucks { + checks[n] = pdp.NewCheck(user, "view", t.ID(), o) + } + allowed, err := r.checkMany(fmt.Sprintf("Fleet list: which %s trucks can %s see?", model.OrgName(o), model.UserName(user)), checks) + if err != nil { + return nil, err + } + for n, t := range trucks { + if allowed[n] { + row := a.summary(t) + row.Org = o + visible = append(visible, row) + } + } + } + return obj{"trucks": visible, "total": total, "log": r.log}, nil +} + +type historyRow struct { + Kind string `json:"kind"` + By string `json:"by"` + At string `json:"at"` + Time string `json:"time"` +} + +// truckPage opens only if the PDP allows view for the acting organization; then one bulk call +// decides which actions and admin controls to show. +func (a *App) truckPage(user, org, key string) (obj, error) { + r := a.newRequest() + truck, ok := a.instances["asset:"+key] + if !ok { + return obj{"notFound": true, "log": r.log}, nil + } + id := truck.ID() + allowed, err := r.checkOne("Open "+truck.Name, user, "view", id, org) + if err != nil { + return nil, err + } + if !allowed { + c := r.last() + return obj{"denied": true, "truck": named{Key: key, Name: truck.Name}, "reason": c.Reason, "code": c.ActingOrg, "log": r.log}, nil + } + + orgSites := a.sitesOf(truck.Tenant) + var asks []pdp.Check + for _, action := range truckActions { + asks = append(asks, pdp.NewCheck(user, action, id, org)) + } + // Site admin rights (assign_roles) on the truck's site and the organization's other sites decide + // details, partner access and moves; platform staff may transfer the truck to another organization. + for _, s := range orgSites { + asks = append(asks, pdp.NewCheck(user, "assign_roles", s.ID(), org)) + } + asks = append(asks, platformCheck(user)) + answers, err := r.checkMany(truck.Name+": which actions and controls to show", asks) + if err != nil { + return nil, err + } + can := map[string]bool{} // "action resource-id" -> allowed + for n, c := range asks { + can[c.Action+" "+c.Resource.ID()] = answers[n] + } + siteAdmin := can["assign_roles "+truck.Parent] + platform := can["manage_users "+model.PlatformOrg] + + actions := []string{} + for _, action := range truckActions { + if can[action+" "+id] { + actions = append(actions, action) + } + } + history := []historyRow{} + for _, h := range a.history[id] { + history = append(history, historyRow{Kind: h.Kind, By: h.By, At: h.At.Format(time.RFC3339), Time: h.At.Format("15:04")}) + } + moveTargets := []named{} + var details, partners, eligible any // null unless the person is an admin of the truck's site + if siteAdmin { + for _, s := range orgSites { + if s.ID() != truck.Parent && can["assign_roles "+s.ID()] { + moveTargets = append(moveTargets, named{Key: s.Key, Name: s.Name}) + } + } + // Scenario 11: the attributes the resource sets read. + details = obj{ + "region": truck.Attributes["region"], + "account": truck.Attributes["served_account"], + "regions": []string{"South", "West", "East"}, + "accounts": []string{"Acct1", "Acct2"}, + } + current, others := []named{}, []named{} + var granted []string + for _, ra := range a.assignments { + if ra.Role == "partner_technician" && ra.ResourceInstance == id { + granted = append(granted, ra.User) + current = append(current, named{Key: ra.User, Name: model.FullName(ra.User)}) + } + } + for _, u := range model.Users { + if model.PartnerOrgs[u.HomeTenant] && a.exists(u.Key) && !slices.Contains(granted, u.Key) { + others = append(others, named{Key: u.Key, Name: model.FullName(u.Key) + ", " + model.OrgName(u.HomeTenant)}) + } + } + partners, eligible = current, others + } + // Scenarios 6 and 13: platform staff may transfer the truck to a site in another organization. + transferTargets := []named{} + if platform { + for _, s := range a.filter(func(i *model.Instance) bool { + return i.Type == "site" && i.Tenant != truck.Tenant && a.tenants[i.Tenant] + }, byName) { + transferTargets = append(transferTargets, named{Key: s.Key, Name: model.OrgName(s.Tenant) + ", " + s.Name}) + } + } + code := model.CodeOf(*truck) + lanes, ok := model.Lanes[code[:min(3, len(code))]] + if !ok { + lanes = []string{"Local run"} + } + + type truckDetail struct { + truckSummary + ServedAccount any `json:"servedAccount"` + Lanes []string `json:"lanes"` + } + return obj{ + "truck": truckDetail{truckSummary: a.summary(truck), ServedAccount: truck.Attributes["served_account"], Lanes: lanes}, + "actions": actions, + "history": history, + "moveTargets": moveTargets, + "details": details, + "transferTargets": transferTargets, + "partners": partners, + "eligiblePartners": eligible, + "log": r.log, + }, nil +} + +type personRole struct { + User string `json:"user"` + Name string `json:"name"` + Role string `json:"role"` +} + +// sitePage shows a site: its trucks, its teams and, to its admins, the people with roles on it. +func (a *App) sitePage(user, org, key string) (obj, error) { + r := a.newRequest() + site, ok := a.instances["site:"+key] + if !ok { + return obj{"notFound": true, "log": r.log}, nil + } + id := site.ID() + allowed, err := r.checkOne("Open "+site.Name, user, "view", id, org) + if err != nil { + return nil, err + } + if !allowed { + c := r.last() + return obj{"denied": true, "site": named{Key: key, Name: site.Name}, "reason": c.Reason, "code": c.ActingOrg, "log": r.log}, nil + } + + trucks := a.children(id, "asset") + asks := []pdp.Check{pdp.NewCheck(user, "assign_roles", id, org)} + for _, t := range trucks { + asks = append(asks, pdp.NewCheck(user, "view", t.ID(), org)) + } + answers, err := r.checkMany(site.Name+": which trucks and controls to show", asks) + if err != nil { + return nil, err + } + visible := []truckSummary{} + for n, t := range trucks { + if answers[1+n] { + visible = append(visible, a.summary(t)) + } + } + var people any // null unless the person may give roles here + if answers[0] { + onSite := []personRole{} + for _, ra := range a.assignments { + if ra.ResourceInstance == id { + onSite = append(onSite, personRole{User: ra.User, Name: model.FullName(ra.User), Role: ra.Role}) + } + } + people = onSite + } + return obj{ + "site": obj{ + "key": key, + "name": site.Name, + "region": a.get(site.Parent).Name, + "owner": model.OrgName(site.Tenant), + }, + "trucks": visible, + "people": people, + "teams": a.teams(id), + "log": r.log, + }, nil +} + +// teams lists a site's teams with their members (scenario 5). +func (a *App) teams(siteID string) []obj { + out := []obj{} + for _, t := range a.children(siteID, "team") { + names := []string{} + for _, m := range a.members[t.ID()] { + names = append(names, model.FullName(m)) + } + out = append(out, obj{"key": t.Key, "name": t.Name, "members": names}) + } + return out +} diff --git a/fleet-ops-demo/internal/server/people.go b/fleet-ops-demo/internal/server/people.go new file mode 100644 index 0000000..b881037 --- /dev/null +++ b/fleet-ops-demo/internal/server/people.go @@ -0,0 +1,448 @@ +package server + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/permitio/permit-golang/pkg/models" + + "fleetops/internal/model" + "fleetops/internal/pdp" + "fleetops/internal/permitapi" + "fleetops/internal/schema" +) + +var tiers = []string{"tier1", "tier2", "tier3"} + +var roleNames = map[string]string{ + "org_admin": "Org admin", "region_manager": "Region manager", "site_manager": "Site manager", + "operator": "Operator", "technician": "Technician", "viewer": "Viewer", + "team_supervisor": "Team supervisor", "partner_technician": "Partner technician", +} + +func roleName(role string) string { + if n, ok := roleNames[role]; ok { + return n + } + return strings.ReplaceAll(role, "_", " ") +} + +// peoplePage lists the acting organization's people and what the person may do with each one: give +// and remove roles on sites (scenario 2), see a team's recent activity (scenario 5), change +// certifications (scenario 9), remove the person from Permit (scenario 15). One bulk call decides it, +// and when it allows none of these the page is denied: the roster is shown only to people who may +// act on it. +func (a *App) peoplePage(user, org string) (obj, error) { + r := a.newRequest() + var people []model.User + for _, u := range model.Users { + if u.HomeTenant == org && a.exists(u.Key) { + people = append(people, u) + } + } + teams := a.filter(func(i *model.Instance) bool { return i.Type == "team" && i.Tenant == org }, byName) + sites := a.sitesOf(org) + asks := []pdp.Check{pdp.NewCheck(user, "manage_users", "org:"+org, org)} + for _, t := range teams { + asks = append(asks, pdp.NewCheck(user, "view_activity", t.ID(), org)) + } + for _, s := range sites { + asks = append(asks, pdp.NewCheck(user, "assign_roles", s.ID(), org)) + } + answers, err := r.checkMany(fmt.Sprintf("People of %s: what may %s see and do?", model.OrgName(org), model.UserName(user)), asks) + if err != nil { + return nil, err + } + if !slices.Contains(answers, true) { + return obj{"denied": true, "reason": r.log[len(r.log)-1].Calls[0].Reason, "log": r.log}, nil + } + can := map[string]bool{} + for n, c := range asks { + can[c.Action+" "+c.Resource.ID()] = answers[n] + } + manage := can["manage_users org:"+org] + + rows := []obj{} + for _, p := range people { + row := obj{ + "key": p.Key, + "name": model.FullName(p.Key), + "title": p.Title, + "certifications": nonNil(a.certifications(p.Key)), + "canCertify": manage, + "canRemove": manage && p.Key != user, + "team": nil, + "activity": nil, + "roles": a.rolesOf(p.Key, org, can, p.Key != user), + } + if team := a.teamOf(p.Key); team != "" { + row["team"] = a.nameOf(team) + if can["view_activity "+team] { + events := []obj{} + for n, e := range a.activity[p.Key] { + if n == 5 { + break + } + events = append(events, obj{"text": e.Text, "time": e.At.Format("15:04")}) + } + row["activity"] = events + } + } + rows = append(rows, row) + } + // The role form lists every site of the organization, so a denied assignment can be tried and shown. + siteList, canGive := []named{}, false + for _, s := range sites { + siteList = append(siteList, named{Key: s.Key, Name: s.Name}) + canGive = canGive || can["assign_roles "+s.ID()] + } + roles := []named{} + for _, role := range siteRoles { + roles = append(roles, named{Key: role, Name: roleName(role)}) + } + return obj{"people": rows, "sites": siteList, "roles": roles, "canGiveRoles": canGive, "log": r.log}, nil +} + +// rolesOf describes a person's roles in an organization; site roles can be removed by the site's +// admins, though not from themselves. +func (a *App) rolesOf(user, org string, can map[string]bool, removable bool) []obj { + out := []obj{} + for _, ra := range a.assignments { + if ra.User != user || ra.Tenant != org { + continue + } + row := obj{"role": ra.Role, "site": nil, "canRemove": false} + switch typ, key, _ := strings.Cut(ra.ResourceInstance, ":"); typ { + case "": + // An org-level role: describe the resource sets its rules grant it (scenario 11). + var sets []string + for _, rule := range schema.ConditionSetRules { + if rule.UserSet == schema.RoleSet(ra.Role) { + if cs, ok := schema.ConditionSetByKey(rule.ResourceSet); ok && !slices.Contains(sets, cs.Name) { + sets = append(sets, cs.Name) + } + } + } + on := strings.Join(sets, ", ") + if on != "" { + on = strings.ToLower(on[:1]) + on[1:] + } + row["label"] = fmt.Sprintf("%s (org-level) on %s", roleName(ra.Role), on) + case "site": + row["label"] = fmt.Sprintf("%s at %s", roleName(ra.Role), a.nameOf(ra.ResourceInstance)) + row["site"] = key + row["canRemove"] = removable && slices.Contains(siteRoles, ra.Role) && can["assign_roles "+ra.ResourceInstance] + case "asset": + row["label"] = fmt.Sprintf("%s on %s", roleName(ra.Role), a.nameOf(ra.ResourceInstance)) + default: + row["label"] = fmt.Sprintf("%s of %s", roleName(ra.Role), a.nameOf(ra.ResourceInstance)) + } + out = append(out, row) + } + slices.SortFunc(out, func(x, y obj) int { return strings.Compare(x["label"].(string), y["label"].(string)) }) + return out +} + +// teamOf is the team a person works on, "" when none. +func (a *App) teamOf(user string) string { + for team, members := range a.members { + if slices.Contains(members, user) { + return team + } + } + return "" +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// personAction changes a person's certifications (scenario 9), gives or removes a role on a site +// (scenario 2), or removes the person (scenario 15). +func (a *App) personAction(body actBody, key string) (obj, error) { + r := a.newRequest() + person, ok := model.UserOf(key) + if !ok || !a.exists(key) { + return obj{"error": "There is no such person.", "log": r.log}, nil + } + user, org, name := body.User, body.Org, model.FullName(key) + fail := func(message string) (obj, error) { return obj{"error": message, "log": r.log}, nil } + if person.HomeTenant != org { + return fail(fmt.Sprintf("%s doesn't work for %s.", name, model.OrgName(org))) + } + + switch body.Action { + case "certify": + var want []string + for _, t := range tiers { + if slices.Contains(body.Certifications, t) { + want = append(want, t) + } + } + stored, err := a.storedAttributes(key) + if err != nil { + return nil, err + } + have := certificationsIn(stored) + changed := func(t string) bool { return slices.Contains(want, t) != slices.Contains(have, t) } + basic, advanced := changed("tier1") || changed("tier2"), changed("tier3") + if !basic && !advanced { + return fail("Nothing changed.") + } + // Tier 1 and tier 2 are the organization's to give: managing its users. Tier 3 only the platform + // operator can give: the same permission on the platform operator's own organization. + if basic { + if ok, err := r.checkOne(fmt.Sprintf("Change %s's tier 1 or tier 2 certification: enforce before writing", name), user, "manage_users", "org:"+org, org); !ok { + return r.refuse(err) + } + } + if advanced { + verb := "Grant tier 3 to" + if !slices.Contains(want, "tier3") { + verb = "Remove tier 3 from" + } + if ok, err := r.platform(fmt.Sprintf("%s %s: platform staff only, enforce before writing", verb, name), user); !ok { + return r.refuse(err) + } + } + attrs := stored + list := make([]any, len(want)) + for n, t := range want { + list[n] = t + } + attrs["certifications"] = list + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.Users.Update(ctx, key, models.UserUpdate{Attributes: attrs}) + return err + }); err != nil { + return nil, fmt.Errorf("updating the attributes of %s: %w", key, err) + } + a.userAttrs[key] = attrs + certs := "no certifications" + if len(want) > 0 { + certs = strings.Join(want, ", ") + } + r.untilData(fmt.Sprintf("%s's certifications as %s", name, certs), "users/"+key, func(d any) bool { + stored, _ := field(field(d, "attributes"), "certifications").([]any) + return len(stored) == len(list) && !slices.ContainsFunc(list, func(t any) bool { return !slices.Contains(stored, t) }) + }) + // What the change does, decided by the PDP on a truck at a site where the person holds a role. + if truck := a.truckFor(key, org); truck != nil { + action, words := "service_basic", "basic service" + if advanced { + action, words = "service_advanced", "advanced service" + } + question := fmt.Sprintf("Can %s do %s on %s now?", name, words, truck.Name) + if _, err := r.checkOne(question, key, action, truck.ID(), org); err != nil { + return nil, err + } + } + message := fmt.Sprintf("%s now holds %s. The certification is a person attribute: it counts in every organization that gives %s a role.", name, certs, model.UserName(key)) + a.record(user, fmt.Sprintf("Changed %s's certifications to %s", name, certs)) + return r.done(message, nil), nil + + case "assign", "unassign": + site, ok := a.instances["site:"+body.Site] + if !ok || site.Tenant != org || !slices.Contains(siteRoles, body.Role) { + return fail("Pick a role and a site.") + } + assign := body.Action == "assign" + role := body.Role + verb, state := "Remove", "is no longer" + if assign { + verb, state = "Give", "is now" + } + label := fmt.Sprintf("%s %s the %s role at %s", verb, name, strings.ToLower(roleName(role)), site.Name) + if ok, err := r.checkOne(label+": enforce before writing", user, "assign_roles", site.ID(), org); !ok { + return r.refuse(err) + } + if err := a.writeAssignment(model.RoleAssignment{User: key, Role: role, ResourceInstance: site.ID(), Tenant: site.Tenant}, assign); err != nil { + return nil, err + } + what := fmt.Sprintf("%s's %s role at %s", name, strings.ToLower(roleName(role)), site.Name) + if !assign { + what = "the removal of " + what + } + r.untilData(what, "role_assignments/user:"+key, func(d any) bool { + roles, _ := field(d, site.ID()).([]any) + return slices.Contains(roles, any(role)) == assign + }) + if trucks := a.children(site.ID(), "asset"); len(trucks) > 0 { // what the role does, decided by the PDP on one of the site's trucks + t, action := trucks[0], roleTruckAction[role] + question := fmt.Sprintf("Can %s %s %s now?", name, strings.ReplaceAll(action, "_", " "), t.Name) + if _, err := r.checkOne(question, key, action, t.ID(), site.Tenant); err != nil { + return nil, err + } + } + message := fmt.Sprintf("%s %s %s at %s. Every truck at the site follows.", name, state, strings.ToLower(roleName(role)), site.Name) + a.record(user, message) + return r.done(message, nil), nil + + case "remove": + if key == user { + return fail("You can't remove yourself.") + } + if ok, err := r.checkOne(fmt.Sprintf("Remove %s from Permit: enforce before writing", name), user, "manage_users", "org:"+org, org); !ok { + return r.refuse(err) + } + truck := a.firstActiveTruck(org) + if truck != nil { + if _, err := r.checkOne(fmt.Sprintf("Before: can %s view %s?", name, truck.Name), key, "view", truck.ID(), org); err != nil { + return nil, err + } + } + if err := permitapi.Call(func(ctx context.Context) error { return a.api.Users.Delete(ctx, key) }, 404); err != nil { + return nil, fmt.Errorf("deleting %s: %w", key, err) + } + delete(a.userAttrs, key) + a.assignments = slices.DeleteFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.User == key }) + for team, keys := range a.members { + a.members[team] = slices.DeleteFunc(keys, func(k string) bool { return k == key }) + } + if truck != nil { + if err := r.untilDecision(fmt.Sprintf("After: can %s view %s?", name, truck.Name), key, "view", truck.ID(), org, false); err != nil { + return nil, err + } + } + // Nothing left behind: no role assignments for the person. + left, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RoleAssignmentRead, error) { + return permitapi.Deref(a.api.RoleAssignments.List(ctx, page, perPage, key, "", "")) + }) + if err != nil { + return nil, fmt.Errorf("listing the role assignments of %s: %w", key, err) + } + message := fmt.Sprintf("Removed %s from Permit. Left behind: %d role assignments. `make reset` brings %s back.", name, len(left), model.UserName(key)) + a.record(user, "Removed "+name+" from Permit") + return r.done(message, nil), nil + } + return fail("Unknown action.") +} + +// truckFor is the first active truck the organization owns and operates at a site where the person +// holds a role, else its first active truck: where a certification change shows. +func (a *App) truckFor(user, org string) *model.Instance { + for _, t := range a.fleetOf(org) { + if t.Tenant == org && str(t.Attributes["lifecycle_state"]) == "active" && operatorOf(t) == org && + slices.ContainsFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.User == user && ra.ResourceInstance == t.Parent }) { + return t + } + } + return a.firstActiveTruck(org) +} + +// firstActiveTruck is an organization's first active truck that it also operates, for showing what a +// change does. +func (a *App) firstActiveTruck(org string) *model.Instance { + for _, t := range a.fleetOf(org) { + if t.Tenant == org && str(t.Attributes["lifecycle_state"]) == "active" && operatorOf(t) == org { + return t + } + } + return nil +} + +// ---- Break-glass access (scenario 14) --------------------------------------------------------------- + +// accessPage shows the acting organization's break-glass grants, to people who may grant them. +func (a *App) accessPage(user, org string) (obj, error) { + r := a.newRequest() + allowed, err := r.checkOne(fmt.Sprintf("Access page: can %s give break-glass access in %s?", model.UserName(user), model.OrgName(org)), user, "assign_roles", "org:"+org, org) + if err != nil { + return nil, err + } + if !allowed { + return obj{"denied": true, "reason": r.last().Reason, "log": r.log}, nil + } + now := time.Now() + grants := []obj{} + for _, u := range model.Users { + for _, g := range a.breakglass(u.Key) { + if g.Tenant != org { + continue + } + grants = append(grants, obj{ + "person": model.FullName(u.Key), + "incident": g.IncidentID, + "until": time.Unix(g.ExpiresAt, 0).Format("15:04:05"), + "active": g.active(now), + "by": model.FullName(g.GrantedBy), + }) + } + } + candidates := []named{} + for _, u := range model.Users { + if u.HomeTenant != org && a.exists(u.Key) && !a.platformStaff(u.Key) { + candidates = append(candidates, named{Key: u.Key, Name: model.FullName(u.Key) + ", " + model.OrgName(u.HomeTenant)}) + } + } + return obj{"grants": grants, "candidates": candidates, "log": r.log}, nil +} + +// accessAction grants break-glass access: view and basic service on the organization's trucks for a +// few minutes, for an incident. The custom policy rule compares the expiry with the clock on every +// decision, so access ends on time with no cleanup job. +func (a *App) accessAction(body actBody) (obj, error) { + r := a.newRequest() + user, org, person := body.User, body.Org, body.Person + fail := func(message string) (obj, error) { return obj{"error": message, "log": r.log}, nil } + if _, ok := model.UserOf(person); !ok || !a.exists(person) || body.Action != "breakglass" { + return fail("Pick a person.") + } + incident := strings.ToUpper(strings.TrimSpace(body.Incident)) + if incident == "" || body.Minutes < 1 || body.Minutes > 60 { + return fail("Give an incident ID and between 1 and 60 minutes.") + } + name := model.FullName(person) + if ok, err := r.checkOne(fmt.Sprintf("Give %s break-glass access in %s: enforce before writing", name, model.OrgName(org)), user, "assign_roles", "org:"+org, org); !ok { + return r.refuse(err) + } + // A truck the person can't see without the grant, to show the change. + var probe *model.Instance + for _, t := range a.fleetOf(org) { + if t.Tenant == org && str(t.Attributes["lifecycle_state"]) == "active" && !slices.ContainsFunc(a.assignments, func(ra model.RoleAssignment) bool { return ra.User == person && ra.ResourceInstance == t.ID() }) { + probe = t + break + } + } + if probe != nil { + if _, err := r.checkOne(fmt.Sprintf("Before: can %s view %s?", name, probe.Name), person, "view", probe.ID(), org); err != nil { + return nil, err + } + } + attrs, err := a.storedAttributes(person) + if err != nil { + return nil, err + } + now := time.Now() + grant := breakglassGrant{Tenant: org, IncidentID: incident, ExpiresAt: now.Add(time.Duration(body.Minutes) * time.Minute).Unix(), GrantedBy: user} + list := []any{} + for _, g := range breakglassIn(attrs) { + if g.active(now) && g.Tenant != org { // expired grants and the old grant here are dropped + list = append(list, map[string]any{"tenant": g.Tenant, "incident_id": g.IncidentID, "expires_at": float64(g.ExpiresAt), "granted_by": g.GrantedBy}) + } + } + list = append(list, map[string]any{"tenant": grant.Tenant, "incident_id": grant.IncidentID, "expires_at": float64(grant.ExpiresAt), "granted_by": grant.GrantedBy}) + attrs["breakglass"] = list + if err := permitapi.Call(func(ctx context.Context) error { + _, err := a.api.Users.Update(ctx, person, models.UserUpdate{Attributes: attrs}) + return err + }); err != nil { + return nil, fmt.Errorf("updating the attributes of %s: %w", person, err) + } + a.userAttrs[person] = attrs + if probe != nil { + if err := r.untilDecision(fmt.Sprintf("During incident %s: can %s view %s?", incident, name, probe.Name), person, "view", probe.ID(), org, true); err != nil { + return nil, err + } + } + until := time.Unix(grant.ExpiresAt, 0).Format("15:04:05") + message := fmt.Sprintf("%s can view and service %s's trucks for incident %s until %s. After that the same checks deny on their own; nothing has to clean up.", name, model.OrgName(org), incident, until) + a.record(user, fmt.Sprintf("Gave %s break-glass access for %s", name, incident)) + return r.done(message, nil), nil +} diff --git a/fleet-ops-demo/internal/server/people_test.go b/fleet-ops-demo/internal/server/people_test.go new file mode 100644 index 0000000..d51e82c --- /dev/null +++ b/fleet-ops-demo/internal/server/people_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "slices" + "strings" + "testing" +) + +// The People page shows an organization's roster, roles and certifications only to someone the PDP +// lets act on it; a broker or a partner technician acting for a customer gets a denial. +func TestPeoplePageNeedsAnAllowedCheck(t *testing.T) { + // The seed's decisions that matter here: Sam may give roles at Dallas; nobody else may do anything. + pdp := &fake{decide: func(user, action, resource, tenant string) bool { + return user == "sam" && action == "assign_roles" && resource == "site:acme-dallas" + }} + _, h := newTestApp(t, pdp) + + _, denied := get(t, h, "/api/people?user=skyler&org=acme") + if denied["denied"] != true || denied["people"] != nil { + t.Errorf("skyler: got %v, want a denial without the roster", denied) + } + if reason, _ := denied["reason"].(string); !strings.Contains(reason, "lets Skyler manage users of Acme Freight") { + t.Errorf("skyler: reason %q", reason) + } + if log, _ := denied["log"].([]any); len(log) != 1 { + t.Errorf("skyler: the log should hold the bulk check, got %v", denied["log"]) + } + + _, shown := get(t, h, "/api/people?user=sam&org=acme") + if people, _ := shown["people"].([]any); shown["denied"] != nil || len(people) == 0 { + t.Errorf("sam: got %v, want the roster", shown) + } +} + +// A certification change is decided and written against Permit's current attributes, not the app's +// copy from start-up: otherwise a tier 3 removed since (a reset, the dashboard) is given back by an +// org admin's tier 2 change, without the platform-staff check. +func TestCertifyReadsPermitsAttributes(t *testing.T) { + pdp := &fake{ + decide: func(user, action, resource, tenant string) bool { return resource != "org:platform" }, + users: map[string]map[string]any{"ivy": {"certifications": []any{"tier1"}}}, + } + a, h := newTestApp(t, pdp) + a.userAttrs["ivy"] = map[string]any{"certifications": []any{"tier1", "tier3"}} // stale: Permit has tier1 only + + _, out := post(t, h, "/api/people/ivy/act", `{"user":"olivia","org":"acme","action":"certify","certifications":["tier1","tier2","tier3"]}`) + if out["denied"] != true { + t.Fatalf("got %v, want the platform-staff check to deny tier 3", out) + } + for _, w := range pdp.written() { + if strings.Contains(w, "/users/ivy") { + t.Errorf("wrote %s", w) + } + } + if got := certificationsIn(a.userAttrs["ivy"]); !slices.Equal(got, []string{"tier1"}) { + t.Errorf("the app's copy is %v, want Permit's [tier1]", got) + } +} + +// Break-glass access writes the person's attributes back whole: Permit's current ones, with only the +// grants changed. +func TestBreakglassKeepsPermitsOtherAttributes(t *testing.T) { + shortWaits(t) + pdp := &fake{users: map[string]map[string]any{"pat": {"certifications": []any{"tier1"}, "home_tenant": "mesa"}}} + a, h := newTestApp(t, pdp) + a.userAttrs["pat"] = map[string]any{"certifications": []any{"tier1", "tier3"}} // stale + + _, out := post(t, h, "/api/access/act", `{"user":"olivia","org":"acme","action":"breakglass","person":"pat","incident":"INC-1","minutes":2}`) + if out["error"] != nil && !strings.Contains(out["error"].(string), "Permit has the change") { + t.Fatalf("got %v", out) + } + var update string + for _, w := range pdp.written() { + if strings.HasPrefix(w, "PATCH ") && strings.Contains(w, "/users/pat ") { + update = w + } + } + if !strings.Contains(update, `"certifications":["tier1"]`) || !strings.Contains(update, `"home_tenant":"mesa"`) || !strings.Contains(update, `"incident_id":"INC-1"`) { + t.Errorf("the update was %q, want Permit's attributes with the new grant", update) + } +} diff --git a/fleet-ops-demo/internal/server/state.go b/fleet-ops-demo/internal/server/state.go new file mode 100644 index 0000000..861feb1 --- /dev/null +++ b/fleet-ops-demo/internal/server/state.go @@ -0,0 +1,377 @@ +// Package server is Fleet Ops: a multi-tenant fleet app whose every authorization decision comes from +// the local PDP, through the Permit Go SDK. +// +// Authorization data (organizations, resources, relationships, role assignments, people's attributes) +// lives in Permit: it is read at start and written through the SDK's API client. App data (team +// members, each truck's dispatch and service history, recent activity) lives in memory. Every check +// the server makes is returned to the browser, which shows it in the PDP log. +package server + +import ( + "context" + "fmt" + "maps" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/permitio/permit-golang/pkg/api" + "github.com/permitio/permit-golang/pkg/models" + + "fleetops/internal/model" + "fleetops/internal/pdp" + "fleetops/internal/permitapi" +) + +var ( + truckActions = []string{"operate", "service_basic", "service_advanced", "decommission"} + siteRoles = []string{"site_manager", "operator", "technician", "viewer"} + // roleTruckAction is the truck action each site role adds, used to show a role change taking effect. + roleTruckAction = map[string]string{"site_manager": "decommission", "operator": "operate", "technician": "service_basic", "viewer": "view"} +) + +// historyRecord is one entry in a truck's history: a dispatch or a service. +type historyRecord struct { + Kind, By string + At time.Time +} + +type activityEvent struct { + Text string + At time.Time +} + +// breakglassGrant is one time-boxed grant (scenario 14), stored in the person's `breakglass` attribute. +type breakglassGrant struct { + Tenant string `json:"tenant"` + IncidentID string `json:"incident_id"` + ExpiresAt int64 `json:"expires_at"` // Unix seconds + GrantedBy string `json:"granted_by"` +} + +func (g breakglassGrant) active(now time.Time) bool { return now.Unix() < g.ExpiresAt } + +type App struct { + pdp *pdp.Client + api *api.PermitApiClient + + // mu guards the state below: page loads read it (RLock), actions change it (Lock). + mu sync.RWMutex + tenants map[string]bool // organizations that exist in Permit + instances map[string]*model.Instance // "type:key" -> instance + members map[string][]string // team id -> member person keys + assignments []model.RoleAssignment // as Permit lists them + userAttrs map[string]map[string]any // person key -> attributes stored in Permit + history map[string][]historyRecord // truck id -> newest first + activity map[string][]activityEvent // person key -> newest first + + seq atomic.Int64 // log entry ids +} + +func New(p *pdp.Client) *App { + return &App{ + pdp: p, + api: p.Permit.Api, + tenants: map[string]bool{}, + instances: map[string]*model.Instance{}, + members: map[string][]string{}, + userAttrs: map[string]map[string]any{}, + history: map[string][]historyRecord{}, + activity: map[string][]activityEvent{}, + } +} + +// Load reads the authorization data from Permit: organizations, resource instances, relationships, +// role assignments and people's attributes. +func (a *App) Load() error { + tenants, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.TenantRead, error) { + return a.api.Tenants.List(ctx, page, perPage) + }) + if err != nil { + return fmt.Errorf("listing tenants: %w", err) + } + rows, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.ResourceInstanceRead, error) { + return permitapi.Deref(a.api.ResourceInstances.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return fmt.Errorf("listing resource instances: %w", err) + } + tuples, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RelationshipTupleRead, error) { + return permitapi.Deref(a.api.RelationshipTuples.List(ctx, page, perPage, "", "", "", "")) + }) + if err != nil { + return fmt.Errorf("listing relationships: %w", err) + } + roles, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.RoleAssignmentRead, error) { + return permitapi.Deref(a.api.RoleAssignments.List(ctx, page, perPage, "", "", "")) + }) + if err != nil { + return fmt.Errorf("listing role assignments: %w", err) + } + users, err := permitapi.ListAll(func(ctx context.Context, page, perPage int) ([]models.UserRead, error) { + return a.api.Users.List(ctx, page, perPage) + }) + if err != nil { + return fmt.Errorf("listing users: %w", err) + } + + a.mu.Lock() + defer a.mu.Unlock() + for _, t := range tenants { + a.tenants[t.Key] = true + } + parentOf := map[string]string{} + for _, t := range tuples { + if t.Relation == "parent" { + parentOf[t.Object] = t.Subject + } + } + for _, row := range rows { + id := row.Resource + ":" + row.Key + name := model.SeedByID[id].Name + if name == "" { + name = defaultName(row.Resource, row.Key) + } + tenant := "" + if row.Tenant != nil { + tenant = *row.Tenant + } + attrs := maps.Clone(row.Attributes) + if attrs == nil { + attrs = map[string]any{} + } + a.instances[id] = &model.Instance{Type: row.Resource, Key: row.Key, Tenant: tenant, Name: name, Parent: parentOf[id], Attributes: attrs} + } + a.assignments = a.assignments[:0] + for _, r := range roles { + ra := model.RoleAssignment{User: r.User, Role: r.Role, Tenant: r.Tenant} + if r.ResourceInstance != nil { + ra.ResourceInstance = *r.ResourceInstance + } + a.assignments = append(a.assignments, ra) + } + for _, u := range users { + a.userAttrs[u.Key] = maps.Clone(u.Attributes) + } + for team, keys := range model.TeamMembers { + for _, k := range keys { + if _, exists := a.userAttrs[k]; exists { + a.members[team] = append(a.members[team], k) + } + } + } + now := time.Now() + for n, e := range model.SeedActivity { + a.activity[e.User] = append(a.activity[e.User], activityEvent{Text: e.Text, At: now.Add(-time.Duration(20+15*n) * time.Minute)}) + } + return nil +} + +// defaultName names instances the seed doesn't know: their display name is rebuilt from the key. +func defaultName(typ, key string) string { + if typ == "asset" { + _, code, found := strings.Cut(key, "-") + if !found { + code = key + } + return "Truck " + strings.ToUpper(code) + } + return key +} + +// nameOf names an instance by "type:key", for the log's explanations. +func (a *App) nameOf(id string) string { + if i, ok := a.instances[id]; ok { + return i.Name + } + if i, ok := model.SeedByID[id]; ok { + return i.Name + } + return id +} + +// record adds to a person's recent activity (scenario 5). +func (a *App) record(user, text string) { + a.activity[user] = append([]activityEvent{{Text: text, At: time.Now()}}, a.activity[user]...) +} + +func (a *App) certifications(user string) []string { return certificationsIn(a.userAttrs[user]) } + +func certificationsIn(attrs map[string]any) []string { + var certs []string + if list, ok := attrs["certifications"].([]any); ok { + for _, c := range list { + if s, ok := c.(string); ok { + certs = append(certs, s) + } + } + } + return certs +} + +func (a *App) breakglass(user string) []breakglassGrant { return breakglassIn(a.userAttrs[user]) } + +func breakglassIn(attrs map[string]any) []breakglassGrant { + var grants []breakglassGrant + list, _ := attrs["breakglass"].([]any) + for _, item := range list { + m, _ := item.(map[string]any) + g := breakglassGrant{IncidentID: str(m["incident_id"]), Tenant: str(m["tenant"]), GrantedBy: str(m["granted_by"])} + if secs, ok := m["expires_at"].(float64); ok { + g.ExpiresAt = int64(secs) + } + grants = append(grants, g) + } + return grants +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +var digits = regexp.MustCompile(`\d+|\D+`) + +// naturalLess sorts "DAL-2" before "DAL-10". +func naturalLess(x, y string) bool { + xs, ys := digits.FindAllString(x, -1), digits.FindAllString(y, -1) + for n := 0; n < len(xs) && n < len(ys); n++ { + if xs[n] == ys[n] { + continue + } + xn, xErr := strconv.Atoi(xs[n]) + yn, yErr := strconv.Atoi(ys[n]) + if xErr == nil && yErr == nil { + return xn < yn + } + return xs[n] < ys[n] + } + return len(xs) < len(ys) +} + +// filter returns the instances that match, sorted by less. +func (a *App) filter(match func(*model.Instance) bool, less func(x, y *model.Instance) bool) []*model.Instance { + var out []*model.Instance + for _, i := range a.instances { + if match(i) { + out = append(out, i) + } + } + sort.Slice(out, func(n, m int) bool { return less(out[n], out[m]) }) + return out +} + +func byKey(x, y *model.Instance) bool { return naturalLess(x.Key, y.Key) } +func byName(x, y *model.Instance) bool { return naturalLess(x.Name, y.Name) } + +func (a *App) children(id, typ string) []*model.Instance { + return a.filter(func(i *model.Instance) bool { return i.Parent == id && i.Type == typ }, byKey) +} + +// sitesOf lists an organization's sites by name. +func (a *App) sitesOf(tenant string) []*model.Instance { + return a.filter(func(i *model.Instance) bool { return i.Type == "site" && i.Tenant == tenant }, byName) +} + +// get returns the instance, or an empty one when it doesn't exist. +func (a *App) get(id string) *model.Instance { + if i, ok := a.instances[id]; ok { + return i + } + return &model.Instance{} +} + +func (a *App) siteOf(truck *model.Instance) *model.Instance { return a.get(truck.Parent) } + +// operatorOf is the organization operating a truck; usually its owner (scenario 7). +func operatorOf(truck *model.Instance) string { + if op := str(truck.Attributes["operator_org"]); op != "" { + return op + } + return truck.Tenant +} + +// fleetOf lists an organization's trucks: the ones it owns and the ones it operates for another +// organization (scenario 7). +func (a *App) fleetOf(org string) []*model.Instance { + return a.filter(func(i *model.Instance) bool { + return i.Type == "asset" && (i.Tenant == org || operatorOf(i) == org) + }, byName) +} + +// platformStaff reports whether a person is one of the platform operator's org admins, as Permit's data has it. The +// PDP decides what they may do; this only labels the organization menu and picks probes. +func (a *App) platformStaff(user string) bool { + return slices.ContainsFunc(a.assignments, func(ra model.RoleAssignment) bool { + return ra.User == user && ra.Role == "org_admin" && ra.ResourceInstance == model.PlatformOrg + }) +} + +// storedAttributes reads a person's attributes from Permit and refreshes the app's copy. A change to +// them is decided and written against what Permit holds now, not what the app read when it started: +// a reset or a dashboard edit since then would otherwise be undone, or decide which checks run. +func (a *App) storedAttributes(user string) (map[string]any, error) { + var read *models.UserRead + if err := permitapi.Call(func(ctx context.Context) error { + var err error + read, err = a.api.Users.Get(ctx, user) + return err + }); err != nil { + return nil, fmt.Errorf("reading %s's attributes from Permit: %w", user, err) + } + attrs := maps.Clone(read.Attributes) + if attrs == nil { + attrs = map[string]any{} + } + a.userAttrs[user] = attrs + return maps.Clone(attrs), nil +} + +// exists reports whether a person is still in Permit (scenario 15 removes people). +func (a *App) exists(user string) bool { + _, ok := a.userAttrs[user] + return ok +} + +type membership struct { + Key string `json:"key"` + Name string `json:"name"` + Via string `json:"via"` // home, platform, granted or breakglass +} + +// memberships are the organizations a person can act for: their own, those where they hold a role +// (for platform staff, every customer organization), and those where they hold an active +// break-glass grant. +func (a *App) memberships(userKey string) []membership { + user, _ := model.UserOf(userKey) + platform := a.platformStaff(userKey) + out := []membership{} + now := time.Now() + for _, t := range model.Tenants { + if !a.tenants[t.Key] { + continue + } + hasRole := slices.ContainsFunc(a.assignments, func(r model.RoleAssignment) bool { return r.User == userKey && r.Tenant == t.Key }) + breakglass := slices.ContainsFunc(a.breakglass(userKey), func(g breakglassGrant) bool { return g.Tenant == t.Key && g.active(now) }) + via := "" + switch { + case t.Key == user.HomeTenant: + via = "home" + case hasRole && platform: + via = "platform" + case hasRole: + via = "granted" + case breakglass: + via = "breakglass" + } + if via != "" { + out = append(out, membership{Key: t.Key, Name: t.Name, Via: via}) + } + } + return out +} diff --git a/fleet-ops-demo/internal/settings/settings.go b/fleet-ops-demo/internal/settings/settings.go new file mode 100644 index 0000000..2787c50 --- /dev/null +++ b/fleet-ops-demo/internal/settings/settings.go @@ -0,0 +1,152 @@ +// Package settings reads .env and builds the one Permit client the app and the commands share. +// +// The .env file is looked up in the working directory, then next to the program and in the folder +// above it (bin/), never further up: a stray .env in a parent folder must not supply a key. +package settings + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + permitconfig "github.com/permitio/permit-golang/pkg/config" + "github.com/permitio/permit-golang/pkg/permit" + "go.uber.org/zap" +) + +type Settings struct { + APIKey string + APIURL string + PDPURL string + OPAURL string + Project string + Env string + Port string + // DemoDir holds .env, docker-compose.yml and policy-repo/. + DemoDir string +} + +// Load reads .env and returns the settings. A variable set in the shell wins over .env, except +// PERMIT_API_KEY: only the key in .env is used, and a different one in the shell is an error, so a +// key exported for another environment can't make `make setup` or `make reset` change that one. +func Load() (Settings, error) { + var dirs []string + if wd, err := os.Getwd(); err == nil { + dirs = append(dirs, wd) + } + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe), filepath.Dir(filepath.Dir(exe))) + } + return load(dirs, os.LookupEnv) +} + +// load reads the first .env found in dirs; env looks up the shell's variables. +func load(dirs []string, env func(string) (string, bool)) (Settings, error) { + envFile, err := find(".env", dirs) + if err != nil { + return Settings{}, err + } + file, err := readEnvFile(envFile) + if err != nil { + return Settings{}, fmt.Errorf("reading %s: %w", envFile, err) + } + key := file["PERMIT_API_KEY"] + if key == "" { + return Settings{}, fmt.Errorf("PERMIT_API_KEY is not set in %s", envFile) + } + if shell, _ := env("PERMIT_API_KEY"); shell != "" && shell != key { + return Settings{}, fmt.Errorf("PERMIT_API_KEY in your shell differs from the one in %s; the demo uses only the key in .env, so run `unset PERMIT_API_KEY` and try again", envFile) + } + get := func(name, fallback string) string { + if v, _ := env(name); v != "" { + return v + } + if v := file[name]; v != "" { + return v + } + return fallback + } + return Settings{ + APIKey: key, + APIURL: get("PERMIT_API_URL", "https://api.permit.io"), + PDPURL: get("PDP_URL", "http://localhost:7766"), + OPAURL: get("OPA_URL", "http://localhost:8181"), + Project: get("PERMIT_PROJECT", ""), + Env: get("PERMIT_ENV", ""), + Port: get("PORT", "3000"), + DemoDir: filepath.Dir(envFile), + }, nil +} + +// MustLoad is Load for commands: it exits with the error. +func MustLoad() Settings { + s, err := Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return s +} + +// Client is the Permit SDK client. The environment-scoped key lets the SDK resolve the project and +// environment by itself. The SDK's own logger is silenced; callers report errors. +func Client(s Settings) *permit.Client { + cfg := permitconfig.NewConfigBuilder(s.APIKey). + WithPdpUrl(s.PDPURL). + WithApiUrl(s.APIURL). + WithLogger(zap.NewNop()). + Build() + return permit.New(cfg) +} + +// find returns the first dirs/name that exists. +func find(name string, dirs []string) (string, error) { + for _, dir := range dirs { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no %s found in %s", name, strings.Join(dirs, " or ")) +} + +// readEnvFile parses KEY=value lines the way Docker Compose reads the same file for the PDP: blank +// lines and # comments are skipped, a value in matching single or double quotes is taken without +// them, and an unquoted value ends at a " #" comment. +func readEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + values := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(key)] = envValue(strings.TrimSpace(value)) + } + return values, sc.Err() +} + +func envValue(v string) string { + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') { + if end := strings.IndexByte(v[1:], v[0]); end >= 0 { + return v[1 : 1+end] + } + } + for n := 1; n < len(v); n++ { + if v[n] == '#' && v[n-1] == ' ' { // as Compose: a tab before # doesn't start a comment + return strings.TrimSpace(v[:n]) + } + } + return v +} diff --git a/fleet-ops-demo/internal/settings/settings_test.go b/fleet-ops-demo/internal/settings/settings_test.go new file mode 100644 index 0000000..70d2512 --- /dev/null +++ b/fleet-ops-demo/internal/settings/settings_test.go @@ -0,0 +1,87 @@ +package settings + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeEnv(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func shell(vars map[string]string) func(string) (string, bool) { + return func(name string) (string, bool) { + v, ok := vars[name] + return v, ok + } +} + +func TestLoadUsesOnlyTheKeyInEnvFile(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, "PERMIT_API_KEY=permit_key_demo\nPERMIT_ENV=demo\n") + t.Run("a different key in the shell is an error", func(t *testing.T) { + _, err := load([]string{dir}, shell(map[string]string{"PERMIT_API_KEY": "permit_key_other"})) + if err == nil || !strings.Contains(err.Error(), "unset PERMIT_API_KEY") { + t.Fatalf("got %v", err) + } + }) + t.Run("the same key in the shell is fine", func(t *testing.T) { + s, err := load([]string{dir}, shell(map[string]string{"PERMIT_API_KEY": "permit_key_demo"})) + if err != nil || s.APIKey != "permit_key_demo" { + t.Fatalf("got %+v, %v", s, err) + } + }) + t.Run("a key only in the shell isn't used", func(t *testing.T) { + empty := t.TempDir() + writeEnv(t, empty, "PERMIT_ENV=demo\n") + if _, err := load([]string{empty}, shell(map[string]string{"PERMIT_API_KEY": "permit_key_other"})); err == nil { + t.Fatal("no error without a key in .env") + } + }) + t.Run("other variables: the shell wins", func(t *testing.T) { + s, err := load([]string{dir}, shell(map[string]string{"PERMIT_ENV": "from-shell", "PORT": "4000"})) + if err != nil || s.Env != "from-shell" || s.Port != "4000" || s.PDPURL != "http://localhost:7766" { + t.Fatalf("got %+v, %v", s, err) + } + }) +} + +// A .env in a parent folder is never used: the key there may belong to any environment. +func TestLoadDoesNotLookInParentFolders(t *testing.T) { + parent := t.TempDir() + writeEnv(t, parent, "PERMIT_API_KEY=permit_key_parent\n") + child := filepath.Join(parent, "demo") + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatal(err) + } + if s, err := load([]string{child}, shell(nil)); err == nil { + t.Fatalf("loaded %s from a parent folder", s.DemoDir) + } +} + +// The parser reads .env the way Docker Compose v2 does (checked with `docker compose config`), so the +// app and the PDP get the same key. +func TestEnvValue(t *testing.T) { + for in, want := range map[string]string{ + `permit_key_abc`: "permit_key_abc", + `"permit_key_abc"`: "permit_key_abc", + `'permit_key_abc'`: "permit_key_abc", + `3000 # app port`: "3000", + "3000\t# app port": "3000\t# app port", + `"a # b"`: "a # b", + `a#b`: "a#b", + `"unclosed`: `"unclosed`, + `https://api.permit.io`: "https://api.permit.io", + `"quoted" # and a remark`: "quoted", + ``: "", + } { + if got := envValue(in); got != want { + t.Errorf("envValue(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/fleet-ops-demo/policy/custom.rego b/fleet-ops-demo/policy/custom.rego new file mode 100644 index 0000000..2aa8717 --- /dev/null +++ b/fleet-ops-demo/policy/custom.rego @@ -0,0 +1,255 @@ +package permit.custom + +import future.keywords.in + +# Installed into the GitOps repo (custom/root.rego) by `make apply-policy`, which stamps the version +# below whenever these rules change. Every decision's debug output carries it (scenario 17). +policy_version := "{{POLICY_VERSION}}" + +# Permit's OR extension point. It stays false: the grants below go through their own allow rule in +# root.rego, which also requires every constraint. +default allow := false + +# ------------------------------------------------------------------------------------------------- +# Facts about the request. +# ------------------------------------------------------------------------------------------------- + +acting_org := object.get(input.resource, "tenant", null) + +instance_key := sprintf("%s:%s", [input.resource.type, input.resource.key]) + +resource_org := data.resource_instances[instance_key].tenant + +user_id := sprintf("user:%s", [input.user.key]) + +# The truck (asset) a request is about. +truck := data.resource_instances[instance_key] { + input.resource.type == "asset" +} + +# Org-level roles the user holds in the acting organization, and roles on its org instance. +acting_org_roles[role] { + some role in data.role_assignments[user_id][sprintf("__tenant:%s", [acting_org])] +} + +acting_org_roles[role] { + some role in data.role_assignments[user_id][sprintf("org:%s", [acting_org])] +} + +# Platform staff are the org admins of the platform operator's own organization, which manages all +# the tenants. +platform_staff { + "org_admin" in data.role_assignments[user_id]["org:platform"] +} + +operational_actions := {"view", "operate", "service_basic", "service_advanced"} + +# ------------------------------------------------------------------------------------------------- +# Tenant isolation: every decision is constrained to the acting organization. +# +# The application sends the acting organization as the check's tenant. When the resource instance +# is stored in Permit, it must belong to that organization, so roles, relationships and attribute +# rules the user holds through other organizations never apply (scenarios 10 and 12). root.rego +# requires `acting_org_ok` on every allow. +# +# Scenario 7: the one exception is a truck owned by one organization and operated by another. The +# operating organization may act on it for operational actions only, and only through a role its own +# people hold in it (`operator_org_allow`). A role the owner granted on the truck or its site reaches +# the truck through relationships whatever tenant is passed, so it must not count while acting for +# the operator: that would get around the owner/operator rule. +# ------------------------------------------------------------------------------------------------- + +operator_access { + input.action in operational_actions + truck.attributes.operator_org == acting_org + truck.tenant != acting_org +} + +default acting_org_ok := false + +# A check about a specific resource (it names a key) must find that resource in the PDP's data. A +# type-level check (no key) has no owning organization to compare. An unknown resource fails closed. +resource_key_given { + input.resource.key != "" +} + +acting_org_ok { + not resource_key_given +} + +acting_org_ok { + resource_org == acting_org +} + +acting_org_ok { + operator_org_allow +} + +acting_org_code := "same_org" { + resource_org == acting_org +} else := "operator_org" { + operator_org_allow +} else := "no_acting_org" { + resource_org + acting_org == null +} else := "acting_org_mismatch" { + resource_org +} else := "unknown_resource" { + resource_key_given +} else := "not_applicable" { + true +} + +acting_org_reason := sprintf("%s belongs to the acting organization '%s'", [instance_key, resource_org]) { + acting_org_code == "same_org" +} else := sprintf("%s belongs to '%s' and is operated by the acting organization '%s', which may use it", [instance_key, resource_org, acting_org]) { + acting_org_code == "operator_org" +} else := sprintf("%s belongs to organization '%s', but the request names no acting organization", [instance_key, resource_org]) { + acting_org_code == "no_acting_org" +} else := sprintf("%s belongs to '%s' and is operated by the acting organization '%s', but no role the user holds in '%s' grants '%s' on it: roles, relationships and rules held through other organizations do not apply", [instance_key, resource_org, acting_org, acting_org, input.action]) { + acting_org_code == "acting_org_mismatch" + operator_access +} else := sprintf("%s belongs to organization '%s', but the request acts for '%s': roles, relationships and rules held through other organizations do not apply", [instance_key, resource_org, acting_org]) { + acting_org_code == "acting_org_mismatch" +} else := sprintf("%s is not in the PDP's data, so its organization can't be checked: denied", [instance_key]) { + acting_org_code == "unknown_resource" +} else := "a type-level check has no resource organization to compare" { + true +} + +default resource_org_or_null := null + +resource_org_or_null := resource_org + +acting_org_debug := { + "allow": acting_org_ok, + "code": acting_org_code, + "reason": acting_org_reason, + "acting_org": acting_org, + "resource_org": resource_org_or_null, +} + +# ------------------------------------------------------------------------------------------------- +# Fleet rules: constraints every allow must also satisfy. root.rego requires `fleet_rules_ok`. +# +# Scenario 13: a truck's lifecycle state limits what anyone may do with it. +# Scenario 9: service needs a certification: tier 1 or tier 2 for basic, tier 3 for advanced. +# Scenario 7: on a truck operated by another organization, the owner keeps ownership actions only. +# ------------------------------------------------------------------------------------------------- + +lifecycle := truck.attributes.lifecycle_state + +default user_certifications := [] + +user_certifications := certs { + certs := data.users[input.user.key].attributes.certifications +} + +certified_basic { + some c in user_certifications + c in {"tier1", "tier2"} +} + +fleet_violations[v] { + lifecycle == "unassigned" + not platform_staff + v := {"code": "unassigned", "reason": sprintf("%s is unassigned: only the platform staff can see or use it", [instance_key])} +} + +fleet_violations[v] { + lifecycle == "decommissioned" + not input.action in {"view", "decommission"} + v := {"code": "decommissioned", "reason": sprintf("%s is decommissioned: only view and decommission are allowed", [instance_key])} +} + +fleet_violations[v] { + input.action == "service_basic" + not certified_basic + v := {"code": "certification_missing", "reason": "basic service needs a tier 1 or tier 2 certification"} +} + +fleet_violations[v] { + input.action == "service_advanced" + not "tier3" in user_certifications + v := {"code": "certification_missing", "reason": "advanced service needs a tier 3 certification"} +} + +fleet_violations[v] { + truck.attributes.operator_org != truck.tenant + acting_org == truck.tenant + input.action in operational_actions + input.action != "view" + v := {"code": "operated_by_other_org", "operator_org": truck.attributes.operator_org, "reason": sprintf("%s is operated by '%s': operational actions belong to the operating organization", [instance_key, truck.attributes.operator_org])} +} + +fleet_rules_ok { + count(fleet_violations) == 0 +} + +# ------------------------------------------------------------------------------------------------- +# Grants. root.rego's second allow rule: one of these, AND tenant isolation, AND the fleet rules. +# +# Scenario 7: the operating organization's people use a truck it operates for another organization, +# by the role they hold in their own organization. +# Scenario 14: a time-boxed break-glass grant for an incident. It expires by the clock, with no +# cleanup job. Grants are a user attribute: [{tenant, incident_id, expires_at (Unix seconds)}]. +# ------------------------------------------------------------------------------------------------- + +operator_grants := { + "org_admin": operational_actions, + "operator": {"view", "operate"}, + "viewer": {"view"}, +} + +operator_org_allow { + operator_access + some role in acting_org_roles + input.action in operator_grants[role] +} + +breakglass_grants[g] { + some g in object.get(data.users[input.user.key].attributes, "breakglass", []) + g.tenant == acting_org + time.now_ns() < g.expires_at * 1000000000 +} + +breakglass_allow { + input.resource.type == "asset" + input.action in {"view", "service_basic"} + resource_org == acting_org + count(breakglass_grants) > 0 +} + +extra_allow { + operator_org_allow +} + +extra_allow { + breakglass_allow +} + +# ------------------------------------------------------------------------------------------------- +# Debug output, merged into every decision's debug by root.rego. +# ------------------------------------------------------------------------------------------------- + +default granted_by := null + +granted_by := "operator_org" { + operator_org_allow +} else := "breakglass" { + breakglass_allow +} + +default fleet_rules_pass := false + +fleet_rules_pass { + fleet_rules_ok +} + +fleet_rules_debug := { + "allow": fleet_rules_pass, + "violations": [v | some v in fleet_violations], + "granted_by": granted_by, + "breakglass": [g | some g in breakglass_grants], + "policy_version": policy_version, +} diff --git a/fleet-ops-demo/policy/custom_test.rego b/fleet-ops-demo/policy/custom_test.rego new file mode 100644 index 0000000..47c21c2 --- /dev/null +++ b/fleet-ops-demo/policy/custom_test.rego @@ -0,0 +1,108 @@ +package permit.custom + +# Unit tests for custom.rego with mock data in the PDP's layout: opa test --v0-compatible policy/ + +import future.keywords.in + +mock := { + "resource_instances": { + "asset:a1": {"tenant": "acme", "attributes": {"lifecycle_state": "active", "operator_org": "acme"}}, + "asset:dec": {"tenant": "acme", "attributes": {"lifecycle_state": "decommissioned", "operator_org": "acme"}}, + "asset:un": {"tenant": "acme", "attributes": {"lifecycle_state": "unassigned", "operator_org": "acme"}}, + "asset:op": {"tenant": "acme", "attributes": {"lifecycle_state": "active", "operator_org": "lonestar"}}, + "site:s1": {"tenant": "acme", "attributes": {}}, + }, + "role_assignments": { + "user:ava": {"org:platform": ["org_admin"], "org:acme": ["org_admin"]}, + "user:olivia": {"org:acme": ["org_admin"]}, + "user:lena": {"org:lonestar": ["org_admin"]}, + "user:quinn": {"__tenant:acme": ["operator"]}, + "user:riley": {"__tenant:lonestar": ["viewer"]}, + # Roles the owner (acme) granted: they reach asset:op through relationships, whatever tenant. + "user:ivy": {"site:s1": ["technician"]}, + "user:jordan": {"site:s1": ["technician"], "site:ls1": ["technician"]}, + }, + "users": { + "tom": {"attributes": {"certifications": ["tier1", "tier3"]}}, + "ivy": {"attributes": {"certifications": ["tier1"]}}, + "jordan": {"attributes": {"certifications": ["tier1", "tier3"]}}, + "dan": {"attributes": {"certifications": []}}, + "pat": {"attributes": {"certifications": ["tier1"], "breakglass": [ + {"tenant": "acme", "incident_id": "INC-1", "expires_at": 4102444800}, + {"tenant": "lonestar", "incident_id": "INC-old", "expires_at": 1000}]}}, + }, +} + +req(user, action, typ, key, tenant) := {"user": {"key": user}, "action": action, "resource": {"type": typ, "key": key, "tenant": tenant}} + +# root.rego's two allow rules as `make apply-policy` installs them (policy.RootAllow), with the +# generated policy's verdict passed in. A role held through relationships makes the generated policy +# allow whatever tenant the check passes, so the custom constraints have to stop it. +root_allow(generated) { + generated + acting_org_ok + fleet_rules_ok +} + +root_allow(_) { + extra_allow + acting_org_ok + fleet_rules_ok +} + +# Tenant isolation +test_same_org_ok { acting_org_ok with input as req("tom", "view", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances } +test_mismatch { not acting_org_ok with input as req("tom", "view", "asset", "a1", "lonestar") with data.resource_instances as mock.resource_instances } +test_mismatch_code { acting_org_code == "acting_org_mismatch" with input as req("tom", "view", "asset", "a1", "lonestar") with data.resource_instances as mock.resource_instances } +test_site_mismatch { not acting_org_ok with input as req("tom", "assign_roles", "site", "s1", "lonestar") with data.resource_instances as mock.resource_instances } +test_unknown_resource_fails_closed { not acting_org_ok with input as req("ava", "view", "asset", "gone", "lonestar") with data.resource_instances as mock.resource_instances } +test_unknown_resource_code { acting_org_code == "unknown_resource" with input as req("ava", "view", "asset", "gone", "lonestar") with data.resource_instances as mock.resource_instances } +test_type_level_ok { acting_org_ok with input as {"user": {"key": "ava"}, "action": "view", "resource": {"type": "asset", "tenant": "acme"}} with data.resource_instances as mock.resource_instances } + +# Scenario 7: owner vs operator +test_operator_access_view { acting_org_ok with input as req("lena", "view", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_operator_code { acting_org_code == "operator_org" with input as req("lena", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_operator_no_ownership { not acting_org_ok with input as req("lena", "decommission", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances } +test_operator_grant_admin { operator_org_allow with input as req("lena", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_operator_grant_viewer_no_operate { not operator_org_allow with input as req("riley", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_operator_grant_viewer_view { operator_org_allow with input as req("riley", "view", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_owner_cannot_operate { not fleet_rules_ok with input as req("quinn", "operate", "asset", "op", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_owner_can_view { fleet_rules_ok with input as req("quinn", "view", "asset", "op", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_owner_can_decommission { fleet_rules_ok with input as req("olivia", "decommission", "asset", "op", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_granted_by { granted_by == "operator_org" with input as req("lena", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } + +# Scenario 7, acting for the operator with a role the owner granted: the owner's technician (ivy), or +# a contractor with a site role in each organization (jordan), must not service the owner's truck by +# switching to the operating organization. The generated policy allows them there, so only the +# custom constraints can deny. +test_owner_granted_role_not_counted_for_operator { not root_allow(true) with input as req("ivy", "service_basic", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_owner_granted_role_view_not_counted_for_operator { not root_allow(true) with input as req("ivy", "view", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_owner_granted_role_code { acting_org_code == "acting_org_mismatch" with input as req("ivy", "service_basic", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_owner_granted_role_reason { contains(acting_org_reason, "but no role the user holds in 'lonestar' grants 'service_basic'") with input as req("ivy", "service_basic", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments } +test_site_role_elsewhere_not_counted_for_operator { not root_allow(true) with input as req("jordan", "service_advanced", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_operator_role_bounds_operator_access { not root_allow(true) with input as req("riley", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_operator_admin_operates { root_allow(false) with input as req("lena", "operate", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_operator_viewer_views { root_allow(false) with input as req("riley", "view", "asset", "op", "lonestar") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_owner_role_views_acting_for_owner { root_allow(true) with input as req("ivy", "view", "asset", "op", "acme") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_owner_role_no_service_acting_for_owner { not root_allow(true) with input as req("ivy", "service_basic", "asset", "op", "acme") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } + +# Scenario 13: lifecycle state +test_decommissioned_view_ok { fleet_rules_ok with input as req("tom", "view", "asset", "dec", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_decommissioned_decommission_ok { fleet_rules_ok with input as req("olivia", "decommission", "asset", "dec", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_decommissioned_service_no { not fleet_rules_ok with input as req("tom", "service_basic", "asset", "dec", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_unassigned_platform { fleet_rules_ok with input as req("ava", "view", "asset", "un", "acme") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } +test_unassigned_tenant_admin { not fleet_rules_ok with input as req("olivia", "view", "asset", "un", "acme") with data.resource_instances as mock.resource_instances with data.role_assignments as mock.role_assignments with data.users as mock.users } + +# Scenario 9: certifications +test_cert_basic_ok { fleet_rules_ok with input as req("ivy", "service_basic", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_cert_basic_missing { not fleet_rules_ok with input as req("dan", "service_basic", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_cert_advanced_missing { not fleet_rules_ok with input as req("ivy", "service_advanced", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_cert_advanced_ok { fleet_rules_ok with input as req("tom", "service_advanced", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } + +# Scenario 14: time-boxed break-glass +test_breakglass_active { breakglass_allow with input as req("pat", "view", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_breakglass_expired { not breakglass_allow with input as req("pat", "view", "asset", "a1", "lonestar") with data.resource_instances as {"asset:a1": {"tenant": "lonestar", "attributes": {}}} with data.users as mock.users } +test_breakglass_no_operate { not breakglass_allow with input as req("pat", "operate", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } +test_breakglass_assets_only { not breakglass_allow with input as req("pat", "view", "site", "s1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } + +test_debug_shape { fleet_rules_debug.allow == false with input as req("dan", "service_basic", "asset", "a1", "acme") with data.resource_instances as mock.resource_instances with data.users as mock.users } diff --git a/fleet-ops-demo/policy/policy.go b/fleet-ops-demo/policy/policy.go new file mode 100644 index 0000000..97b8be3 --- /dev/null +++ b/fleet-ops-demo/policy/policy.go @@ -0,0 +1,95 @@ +// Package policy holds the custom Rego this demo adds to Permit's generated policy, and the allow +// rules root.rego needs to require it. `make apply-policy` installs both in the GitOps repo. +package policy + +import ( + _ "embed" + "regexp" + "strings" +) + +//go:embed custom.rego +var customTemplate string + +const versionPlaceholder = "{{POLICY_VERSION}}" + +var versionLine = regexp.MustCompile(`(?m)^policy_version := ".*"$`) + +// Custom is custom/root.rego with the given version stamped in. +func Custom(version string) string { + return strings.Replace(customTemplate, versionPlaceholder, version, 1) +} + +// Version is the version stamped in an installed custom/root.rego, "" when none. The PDP's copy of +// the module is read with it too. +func Version(installed string) string { + if m := versionValue.FindStringSubmatch(installed); m != nil { + return m[1] + } + return "" +} + +var versionValue = regexp.MustCompile(`(?m)^policy_version := "([^"]*)"`) + +// SameRules reports whether an installed custom/root.rego has these rules, whatever its version. +func SameRules(installed string) bool { + return versionLine.ReplaceAllString(installed, `policy_version := "`+versionPlaceholder+`"`) == customTemplate +} + +// RootAllow replaces the allow rules of Permit's generated root.rego: the generated policy, or a grant +// in custom/root.rego, must allow, and every allow requires the custom constraints. +const RootAllow = `# Fleet Ops demo: the generated policy (RBAC, ReBAC, ABAC) or a grant in custom/root.rego must allow, +# AND the request must satisfy the tenant-isolation rule and the fleet rules in custom/root.rego. +# Custom code adds constraints, and grants only through the second rule. +# Schema and dashboard changes leave this file alone; Permit replaces it only when it merges a policy +# library update into this branch. If ` + "`make verify`" + ` then reports the rules missing, run +# ` + "`make apply-policy`" + ` in fleet-ops-demo to apply them again. +allow { + policies.allow + custom.acting_org_ok + custom.fleet_rules_ok +} + +allow { + custom.extra_allow + custom.acting_org_ok + custom.fleet_rules_ok +}` + +var ( + regoComment = regexp.MustCompile(`(?m)#.*$`) + allowHead = regexp.MustCompile(`(?m)^allow\b`) + allowRule = regexp.MustCompile(`(?m)^allow\s*\{([^}]*)\}`) +) + +// RequiredByRoot reports whether every allow rule in a root.rego requires both custom constraints, +// custom.acting_org_ok and custom.fleet_rules_ok, in any order: the rules RootAllow installs, or an +// equivalent edit. Comments are ignored, so rules that are commented out count as absent, and an +// allow rule of any other shape counts as not requiring them. +func RequiredByRoot(root string) bool { + root = regoComment.ReplaceAllString(root, "") + rules := allowRule.FindAllStringSubmatch(root, -1) + if len(rules) == 0 || len(rules) != len(allowHead.FindAllStringIndex(root, -1)) { + return false + } + for _, rule := range rules { + if !hasStatement(rule[1], "custom.acting_org_ok") || !hasStatement(rule[1], "custom.fleet_rules_ok") { + return false + } + } + return true +} + +// hasStatement reports whether a rule body has the expression as a statement of its own, so a +// negated or compared use (`not custom.acting_org_ok`) doesn't count. +func hasStatement(body, expr string) bool { + for _, line := range strings.FieldsFunc(body, func(r rune) bool { return r == '\n' || r == ';' }) { + if strings.TrimSpace(line) == expr { + return true + } + } + return false +} + +// RootDebug is the debug result line: Permit's debug output plus both custom verdicts. +const RootDebug = `result := object.union(debug.debug, {"acting_org": custom.acting_org_debug, "fleet_rules": custom.fleet_rules_debug})` diff --git a/fleet-ops-demo/policy/policy_test.go b/fleet-ops-demo/policy/policy_test.go new file mode 100644 index 0000000..2093cbb --- /dev/null +++ b/fleet-ops-demo/policy/policy_test.go @@ -0,0 +1,79 @@ +package policy + +import ( + "strings" + "testing" + + "fleetops/internal/model" +) + +func TestRequiredByRoot(t *testing.T) { + const generated = "default allow := false\n\nallow {\n\tpolicies.allow\n}\n\nallow {\n\tcustom.allow\n}\n" + const wired = "default allow := false\n\nallow {\n\tpolicies.allow\n\tcustom.acting_org_ok\n\tcustom.fleet_rules_ok\n}\n\nallow {\n\tcustom.extra_allow\n\tcustom.acting_org_ok\n\tcustom.fleet_rules_ok\n}\n" + // The custom requirements are there, but only as comments next to Permit's own rules. + const commentedOut = generated + "\n# allow {\n# \tpolicies.allow\n# \tcustom.acting_org_ok\n# \tcustom.fleet_rules_ok\n# }\n" + const oneUnwired = "allow {\n\tpolicies.allow\n\tcustom.acting_org_ok\n\tcustom.fleet_rules_ok\n}\n\nallow {\n\tcustom.allow\n}\n" + // The same requirements in the other order mean the same. + const swapped = "allow {\n\tpolicies.allow\n\tcustom.fleet_rules_ok\n\tcustom.acting_org_ok\n}\n" + const negated = "allow {\n\tpolicies.allow\n\tnot custom.acting_org_ok\n\tcustom.fleet_rules_ok\n}\n" + const crlf = "allow {\r\n\tpolicies.allow\r\n\tcustom.acting_org_ok\r\n\tcustom.fleet_rules_ok\r\n}\r\n" + + for name, tc := range map[string]struct { + root string + want bool + }{ + "generated by Permit": {generated, false}, + "every allow requires them": {wired, true}, + "as RootAllow installs them": {"package permit.root\n\ndefault allow := false\n\n" + RootAllow + "\n", true}, + "requirements commented out": {commentedOut, false}, + "one allow not wired": {oneUnwired, false}, + "no allow rules": {"default allow := false\n", false}, + "requirements in the other order": {swapped, true}, + "a requirement negated": {negated, false}, + "Windows line endings": {crlf, true}, + "an allow rule of another shape": {wired + "allow = true { input.x }\n", false}, + "allowing_sources is not an allow rule": {wired + "allowing_sources := policies.__allow_sources\n", true}, + } { + t.Run(name, func(t *testing.T) { + if got := RequiredByRoot(tc.root); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +// Reordering the requirements in RootAllow must not make `make verify` call the installed rules +// missing: the writer (RootAllow) and the reader (RequiredByRoot) live here, together. +func TestRootAllowReorderedStillRecognized(t *testing.T) { + reordered := strings.NewReplacer( + "\tcustom.acting_org_ok\n\tcustom.fleet_rules_ok", "\tcustom.fleet_rules_ok\n\tcustom.acting_org_ok", + ).Replace(RootAllow) + if reordered == RootAllow { + t.Fatal("the replacement didn't apply: update this test with RootAllow") + } + if !RequiredByRoot("default allow := false\n\n" + reordered + "\n") { + t.Error("RootAllow with its requirements reordered isn't recognized") + } +} + +func TestVersion(t *testing.T) { + for name, tc := range map[string]struct{ module, want string }{ + "stamped": {Custom("2026-10-13T10:00:00Z"), "2026-10-13T10:00:00Z"}, + "not installed": {"", ""}, + "CRLF": {"package permit.custom\r\npolicy_version := \"v2\"\r\n", "v2"}, + } { + t.Run(name, func(t *testing.T) { + if got := Version(tc.module); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// custom.rego names the platform operator's org instance literally (platform staff); it must be the +// instance the app checks platform-only actions on. +func TestCustomNamesThePlatformOrg(t *testing.T) { + if !strings.Contains(Custom(""), `"`+model.PlatformOrg+`"`) { + t.Errorf("custom.rego doesn't name %q", model.PlatformOrg) + } +} diff --git a/fleet-ops-demo/web/app.js b/fleet-ops-demo/web/app.js new file mode 100644 index 0000000..12e946d --- /dev/null +++ b/fleet-ops-demo/web/app.js @@ -0,0 +1,529 @@ +// Fleet Ops front end. Every allow/deny comes from the local PDP, through the Permit Go SDK on the +// server, and every check the server made for a page or an action is added to the PDP log on the right. + +const $ = (sel) => document.querySelector(sel); +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +async function api(path, body) { + const init = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; + const res = await fetch(path, init); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? data.detail ?? `Request failed with status ${res.status}`); + return data; +} + +// Remembers who is signed in across reloads; the app works without it. +const saved = { + get: (k) => { try { return sessionStorage.getItem(k); } catch { return null; } }, + set: (k, v) => { try { sessionStorage.setItem(k, v); } catch { /* storage unavailable */ } }, +}; + +const state = { meta: null, user: null, org: null, orgs: [], log: [], flash: null, busy: false }; + +const ALL = '*'; // "All my organizations": the fleet list, each truck checked acting for its own organization +const orgName = (key) => (key === ALL ? 'all your organizations' : state.orgs.find((o) => o.key === key)?.name ?? key); +const query = () => `user=${encodeURIComponent(state.user)}&org=${encodeURIComponent(state.org)}`; +const route = () => location.hash.match(/^#\/(truck|site|people|access|organizations)(?:\/([a-z0-9-]+))?/); +const VIA = { home: '', platform: ' (platform operator)', granted: ' (granted access)', breakglass: ' (break-glass)' }; +const ROLE_NAMES = { site_manager: 'Site manager', operator: 'Operator', technician: 'Technician', viewer: 'Viewer' }; +const TIERS = ['tier1', 'tier2', 'tier3']; + +// Switchers ------------------------------------------------------------------------------------------- + +// Signing in lands in the person's home organization unless `keep` is one of theirs. Person and +// organization are set together, so a page rendered meanwhile never pairs one with the other's old value. +async function signIn(user, keep) { + const { orgs } = await api(`/api/orgs?user=${encodeURIComponent(user)}`); + const org = orgs.some((o) => o.key === keep) || (keep === ALL && orgs.length > 1) + ? keep + : (orgs.find((o) => o.via === 'home') ?? orgs[0])?.key; + Object.assign(state, { user, orgs, org }); + saved.set('user', user); + saved.set('org', org ?? ''); +} + +function renderSwitchers() { + const groups = new Map(); + for (const u of state.meta.users) groups.set(u.org, [...(groups.get(u.org) ?? []), u]); + $('#user').innerHTML = [...groups] + .map(([org, users]) => `${users + .map((u) => ``) + .join('')}`) + .join(''); + $('#org').innerHTML = state.orgs + .map((o) => ``) + .join('') + (state.orgs.length > 1 ? `` : ''); +} + +function renderNav() { + const current = route()?.[1] ?? 'fleet'; + document.querySelectorAll('[data-nav]').forEach((a) => { + const on = a.dataset.nav === current || (a.dataset.nav === 'fleet' && ['truck', 'site'].includes(current)); + if (on) a.setAttribute('aria-current', 'page'); + else a.removeAttribute('aria-current'); + }); +} + +// Pages ------------------------------------------------------------------------------------------------- + +let pageSeq = 0; + +// Fetches a page's data and logs the checks behind it. Returns null when a newer page load started +// meanwhile, so a slower, older answer never replaces the page the person is on now. +async function pageData(path) { + const seq = ++pageSeq; + const data = await api(`${path}${path.includes('?') ? '&' : '?'}${query()}`); + addLog(data.log); + return seq === pageSeq ? data : null; +} + +async function render() { + renderNav(); + try { + const r = route(); + const page = r?.[1]; + if (!state.org && page !== 'organizations') { + $('#page').innerHTML = `

No organization

${esc(state.meta.users.find((u) => u.key === state.user)?.name)} can't act for any organization right now.

`; + return; + } + if (state.org === ALL && page && page !== 'organizations') { + $('#page').innerHTML = '

Pick an organization

This page acts for one organization at a time. Pick one in the Organization menu.

'; + return; + } + if (page === 'truck') await truckPage(r[2]); + else if (page === 'site') await sitePage(r[2]); + else if (page === 'people') await peoplePage(); + else if (page === 'access') await accessPage(); + else if (page === 'organizations') await orgsPage(); + else await fleetPage(); + } catch (err) { + $('#page').innerHTML = `

Something went wrong

${esc(err.message)}

Check that the PDP container and the app server are running.

`; + } +} + +function statusBadge(t) { + if (t.lifecycle === 'decommissioned') return 'Decommissioned'; + if (t.lifecycle === 'unassigned') return 'Unassigned'; + if (t.lifecycle === 'transferring') return 'Transferring'; + return 'Active'; +} + +function operatorBadge(t) { + return t.operator ? ` Operated by ${esc(t.operator)}` : ''; +} + +function takeFlash() { + const f = state.flash; + state.flash = null; + return f ? `

${esc(f.text)}

` : ''; +} + +function deniedHtml(crumbs, name, reason, action = 'view') { + state.flash = null; + return `${crumbs}
+

You don't have access to ${esc(name)}

+

Acting for ${esc(orgName(state.org))}, the PDP denied ${esc(action)}.

+

${esc(reason)}

`; +} + +const options = (list, selected) => list.map((o) => ``).join(''); + +function truckRows(trucks, { site = true, org = false } = {}) { + return trucks + .map((t) => `${esc(t.name)}${operatorBadge(t)}${ + org ? `${esc(orgName(t.org))}` : '' + }${ + site ? `${org ? esc(t.site) : `${esc(t.site)}`}${esc(t.region)}` : '' + }${statusBadge(t)}`) + .join(''); +} + +async function fleetPage() { + const data = await pageData('/api/fleet'); + if (!data) return; + const n = data.trucks.length; + const all = state.org === ALL; + $('#page').innerHTML = ` +

Fleet

+

${n} ${n === 1 ? 'truck' : 'trucks'} in ${esc(orgName(state.org))}${all ? '. Each one was checked acting for its own organization.' : ''}

+ ${takeFlash()} + ${n + ? `${all ? '' : ''}${truckRows(data.trucks, { org: all })}
TruckOrganizationSiteRegionStatus
` + : `

${data.total === 0 + ? `${esc(orgName(state.org))} has no trucks.` + : `You don't have access to any trucks in ${esc(orgName(state.org))}.`}

`}`; +} + +async function truckPage(key) { + const data = await pageData(`/api/trucks/${key}`); + if (!data) return; + if (data.notFound) { + $('#page').innerHTML = `

Fleet

There is no truck called ${esc(key)}

`; + return; + } + if (data.denied) { + $('#page').innerHTML = deniedHtml(`

Fleet / ${esc(data.truck.name)}

`, data.truck.name, data.reason); + return; + } + + const t = data.truck; + const can = (a) => data.actions.includes(a); + const active = t.lifecycle === 'active'; + const buttons = [ + can('operate') + ? `` + : '', + can('service_basic') ? '' : '', + can('service_advanced') ? '' : '', + can('decommission') && active ? '' : '', + can('decommission') && !active ? '' : '', + ].join(''); + + const history = data.history.length + ? `
    ${data.history.map((h) => `
  • ${esc(h.kind)}${esc(h.by)}, ${esc(h.time)}
  • `).join('')}
` + : '

Nothing logged yet.

'; + + const details = data.details + ? `

Details

+

The attribute-based sets read these values: assets where region = South, assets where served_account = Acct1.

+
+ + + +
` + : ''; + + const move = data.moveTargets.length + ? `
` + : ''; + const transfer = data.transferTargets.length + ? `
` + : ''; + const fleetAdmin = move || transfer + ? `

Fleet management

+

${move ? 'A move replaces one parent link; no role assignment changes.' : ''}${move && transfer ? ' ' : ''}${transfer ? 'A transfer to another organization is for platform staff.' : ''}

+ ${move}${transfer}` + : ''; + + const partners = data.partners === null ? '' : ` +

Partner access

+

Technicians from partner companies get this truck only, and nothing else in ${esc(t.owner)}.

+ ${data.partners.length + ? `
    ${data.partners.map((p) => `
  • ${esc(p.name)}
  • `).join('')}
` + : '

No partner has access to this truck.

'} + ${data.eligiblePartners.length + ? `
` + : ''}`; + + $('#page').innerHTML = ` +

Fleet / ${esc(t.site)} / ${esc(t.name)}

+

${esc(t.name)}

${statusBadge(t)}${operatorBadge(t)}
+
+
Owner
${esc(t.owner)}
+ ${t.operator ? `
Operated by
${esc(t.operator)}
` : ''} +
Site
${esc(t.site)}
+
Region
${esc(t.region)}
+
Served account
${esc(t.servedAccount)}
+
Lifecycle state
${esc(t.lifecycle)}
+
+
${buttons || '

You can view this truck. Your roles allow nothing else here.

'}
+ ${takeFlash()} +

History

${history} + ${details} + ${fleetAdmin} + ${partners}`; +} + +async function sitePage(key) { + const data = await pageData(`/api/sites/${key}`); + if (!data) return; + if (data.notFound) { + $('#page').innerHTML = `

Fleet

There is no site called ${esc(key)}

`; + return; + } + if (data.denied) { + $('#page').innerHTML = deniedHtml(`

Fleet / ${esc(data.site.name)}

`, data.site.name, data.reason); + return; + } + const s = data.site; + const n = data.trucks.length; + const teams = data.teams.length + ? `

Teams

    ${data.teams + .map((t) => `
  • ${esc(t.name)}${t.members.length ? esc(t.members.join(', ')) : 'No members'}
  • `) + .join('')}
` + : ''; + const people = data.people === null ? '' : ` +

People with roles here

+

Roles given here reach every truck at ${esc(s.name)}. Give and remove them on the People page.

+ ${data.people.length + ? `
    ${data.people.map((p) => `
  • ${esc(p.name)}${esc(ROLE_NAMES[p.role] ?? p.role)}
  • `).join('')}
` + : '

Nobody has a role on this site directly.

'}`; + + $('#page').innerHTML = ` +

Fleet / ${esc(s.name)}

+

${esc(s.name)}

+
+
Organization
${esc(s.owner)}
+
Region
${esc(s.region)}
+
+ ${takeFlash()} +

Trucks

+ ${n ? `${truckRows(data.trucks, { site: false })}
TruckStatus
` : '

No trucks you can see at this site.

'} + ${teams} + ${people}`; +} + +async function peoplePage() { + const data = await pageData('/api/people'); + if (!data) return; + if (data.denied) { + $('#page').innerHTML = deniedHtml('

People

', `the people of ${orgName(state.org)}`, data.reason, 'manage_users'); + return; + } + const giveForm = data.canGiveRoles + ? `

Give a role

+

A role on a site reaches every truck there. The PDP checks assign_roles on the site you pick.

+
+ + + + +
` + : ''; + const rows = data.people.map((p) => { + const roles = p.roles.length + ? `
    ${p.roles.map((r) => `
  • ${esc(r.label)}${r.canRemove ? ` ` : ''}
  • `).join('')}
` + : '

No roles

'; + const certs = p.certifications.length ? p.certifications.map((c) => `${esc(c)}`).join(' ') : 'No certifications'; + const editor = p.canCertify + ? `
${TIERS.map((tier) => ``).join('')} +
` + : ''; + const team = p.team === null + ? '' + : `

${esc(p.team)}: recent activity

${p.activity === null + ? '

Only the team\'s supervisor can see this.

' + : p.activity.length + ? `
    ${p.activity.map((e) => `
  • ${esc(e.text)} ${esc(e.time)}
  • `).join('')}
` + : '

No activity yet.

'}
`; + const remove = p.canRemove ? `` : ''; + return `
  • ${esc(p.name)} ${esc(p.title)}${remove}
    + ${roles}
    ${certs}
    ${editor}${team}
  • `; + }); + $('#page').innerHTML = ` +

    People

    +

    ${esc(orgName(state.org))}: its people, their roles, and what you may do with each one.

    + ${takeFlash()} + ${giveForm} + ${rows.length ? `
      ${rows.join('')}
    ` : '

    Nobody works here.

    '}`; +} + +async function accessPage() { + const data = await pageData('/api/access'); + if (!data) return; + if (data.denied) { + $('#page').innerHTML = deniedHtml('

    Access

    ', `break-glass access in ${orgName(state.org)}`, data.reason, 'assign_roles'); + return; + } + const grants = data.grants.length + ? `
      ${data.grants.map((g) => `
    • ${esc(g.person)} ${esc(g.incident)}, by ${esc(g.by)}${g.active ? 'Active until' : 'Expired'} ${esc(g.until)}
    • `).join('')}
    ` + : '

    No break-glass grants yet.

    '; + $('#page').innerHTML = ` +

    Access

    +

    Break-glass access gives someone outside ${esc(orgName(state.org))} view and basic service on its trucks for a few minutes, for an incident. It ends by the clock; nothing has to clean it up.

    + ${takeFlash()} +

    Break-glass grants

    + ${grants} +
    + + + + +
    `; +} + +async function orgsPage() { + const data = await pageData('/api/organizations'); + if (!data) return; + if (data.denied) { + state.flash = null; + $('#page').innerHTML = `

    Organizations

    Only the platform staff manage organizations

    +

    The PDP denied manage_users on Fleet Ops, the platform operator's organization.

    ${esc(data.reason)}

    `; + return; + } + const rows = data.orgs.map((o) => { + const targets = data.mergeTargets.filter((t) => t.key !== o.key); + const merge = !o.partner && targets.length + ? ` + ` + : ''; + return `${esc(o.name)}
    ${esc(o.description)}
    ${o.trucks ?? 0}${o.resources ?? 0}${o.assignments ?? 0} +
    ${merge}
    `; + }); + $('#page').innerHTML = ` +

    Organizations

    +

    The platform operator's console. Platform staff can delete a customer organization, or merge it into another as a sub-organization. Do these last in a run; make reset brings everything back.

    + ${takeFlash()} + ${rows.length + ? `${rows.join('')}
    OrganizationTrucksResourcesRole assignments
    ` + : '

    No customer organizations.

    '}`; +} + +async function act(button) { + if (state.busy) return; + const action = button.dataset.act; + const [, kind, key] = route() ?? []; + const person = action === 'assign' ? $('#give-person')?.value : button.dataset.key; + const url = { + truck: `/api/trucks/${key}/act`, + people: `/api/people/${person}/act`, + access: '/api/access/act', + organizations: `/api/organizations/${button.dataset.key}/act`, + }[kind]; + if (!url) return; + const params = { + lane: action === 'dispatch' ? $('#lane')?.value : undefined, + partner: button.dataset.partner ?? (action === 'grant' ? $('#partner')?.value : undefined), + site: button.dataset.site ?? (action === 'move' ? $('#move-site')?.value : action === 'transfer' ? $('#transfer-site')?.value : action === 'assign' ? $('#give-site')?.value : undefined), + person: action === 'breakglass' ? $('#bg-person')?.value : undefined, + role: button.dataset.role ?? (action === 'assign' ? $('#give-role')?.value : undefined), + region: action === 'edit_details' ? $('#detail-region')?.value : undefined, + account: action === 'edit_details' ? $('#detail-account')?.value : undefined, + certifications: action === 'certify' ? [...document.querySelectorAll(`input[name="cert-${person}"]:checked`)].map((i) => i.value) : undefined, + incident: action === 'breakglass' ? $('#bg-incident')?.value : undefined, + minutes: action === 'breakglass' ? Number($('#bg-minutes')?.value) : undefined, + target: action === 'merge' ? $(`#merge-${button.dataset.key}`)?.value : undefined, + }; + state.busy = true; + document.querySelectorAll('#page .btn').forEach((b) => { b.disabled = true; }); + try { + const res = await api(url, { user: state.user, org: state.org, action, ...params }); + addLog(res.log); + state.flash = res.ok + ? { kind: 'ok', text: res.message } + : { kind: 'error', text: res.denied ? `The PDP denied this. ${res.message}` : res.error }; + if (res.redirect) location.hash = res.redirect; + await signIn(state.user, state.org); // memberships can change: grants, deleted or merged organizations + renderSwitchers(); + } catch (err) { + state.flash = { kind: 'error', text: err.message }; + } finally { + state.busy = false; + } + await render(); +} + +// PDP log ------------------------------------------------------------------------------------------ + +function addLog(entries = []) { + if (!entries.length) return; + state.log = [...entries.slice().reverse(), ...state.log].slice(0, 200); + renderLog(entries.length); +} + +const valueText = (v) => (v === null || v === undefined ? 'none' : Array.isArray(v) ? v.join(', ') || 'none' : String(v)); + +function detailHtml(c) { + const parts = []; + if (c.route) parts.push(`

    Relationship route:

      ${c.route.map((r) => `
    1. ${esc(r)}
    2. `).join('')}
    `); + if (c.rule) { + parts.push(`

    Rule conditions, with the values the PDP evaluated:

      ${c.rule + .map((r) => `
    • ${r.ok ? '✓' : '✗'} ${esc(r.label)} ${esc(valueText(r.actual))}
    • `) + .join('')}
    `); + } + if (c.actingOrg) parts.push(`

    Tenant isolation: ${esc(c.actingOrg)}

    `); + if (c.fleetRule) parts.push(`

    Fleet rule that denied: ${esc(c.fleetRule)}

    `); + if (c.grant) parts.push(`

    Allowed by the custom grant: ${esc(c.grant)}

    `); + if (c.policyVersion) parts.push(`

    Custom policy version: ${esc(c.policyVersion)}

    `); + return parts.join(''); +} + +// A denied check in a bulk: the rule conditions that failed, or else the PDP's reason. +function denyWhy(c) { + if (c.allow) return ''; + const unmet = !c.fleetRule && c.actingOrg !== 'acting_org_mismatch' && c.rule ? c.rule.filter((r) => !r.ok) : []; + const text = unmet.length ? `Not met: ${unmet.map((r) => `${r.label} (${valueText(r.actual)})`).join('; ')}` : c.reason; + return text ? `${esc(text)}` : ''; +} + +function entryHtml(e, fresh) { + const ms = `${e.ms.toFixed(1)} ms`; + if (e.kind === 'check') { + const c = e.calls[0]; + const verdict = c.allow ? 'allow' : 'deny'; + return `
  • +
    ${c.allow ? 'ALLOW' : 'DENY'}${esc(e.purpose)}${ms}
    + ${esc(e.code)} +

    ${esc(c.reason)}

    +
    Why
    ${detailHtml(c)} +
    PDP response
    ${esc(JSON.stringify(e.debug, null, 2))}
    +
  • `; + } + const allowed = e.calls.filter((c) => c.allow).length; + return `
  • +
    ${allowed} of ${e.calls.length} allowed${esc(e.purpose)}${ms}
    + ${esc(e.code)} +
    Each check
      ${e.calls + .map((c) => `
    • ${c.allow ? '✓' : '✗'} ${esc(c.user)} ${esc(c.action)} ${esc(c.resource.type)}:${esc(c.resource.key)} @${esc(c.resource.tenant)}${denyWhy(c)}
    • `) + .join('')}
    +
  • `; +} + +function renderLog(fresh = 0) { + $('#log').innerHTML = state.log.map((e, i) => entryHtml(e, i < fresh)).join(''); +} + +async function refreshStatus() { + let s = { pdp: false, actingOrgRule: false }; + try { + s = await api('/api/status'); + } catch { + // shown below as not connected + } + $('#status').innerHTML = ` + ${s.pdp ? 'Local PDP connected' : 'PDP not reachable'} + ${s.actingOrgRule ? 'Custom rules loaded' : 'Custom rules missing'}`; +} + +// Start ---------------------------------------------------------------------------------------------- + +async function init() { + state.meta = await api('/api/meta'); + const users = state.meta.users.map((u) => u.key); + await signIn(users.includes(saved.get('user')) ? saved.get('user') : users[0], saved.get('org')); + renderSwitchers(); + refreshStatus(); + setInterval(refreshStatus, 15000); + + $('#user').addEventListener('change', async (ev) => { + await signIn(ev.target.value); + renderSwitchers(); + await render(); + }); + $('#org').addEventListener('change', async (ev) => { + state.org = ev.target.value; + saved.set('org', state.org); + await render(); + }); + $('#page').addEventListener('click', (ev) => { + // In the all-organizations list, opening a truck switches to the organization it was checked for. + const link = ev.target.closest('a[data-org]'); + if (link) { + state.org = link.dataset.org; + saved.set('org', state.org); + renderSwitchers(); + } + const button = ev.target.closest('[data-act]'); + if (button) act(button); + }); + $('#log-clear').addEventListener('click', () => { + state.log = []; + renderLog(); + }); + window.addEventListener('hashchange', render); + await render(); +} + +init().catch((err) => { + $('#page').innerHTML = `

    Fleet Ops can't start

    ${esc(err.message)}

    `; +}); diff --git a/fleet-ops-demo/web/embed.go b/fleet-ops-demo/web/embed.go new file mode 100644 index 0000000..6c041f9 --- /dev/null +++ b/fleet-ops-demo/web/embed.go @@ -0,0 +1,7 @@ +// Package web holds the page: index.html, app.js and styles.css, built into the server binary. +package web + +import "embed" + +//go:embed index.html app.js styles.css +var Files embed.FS diff --git a/fleet-ops-demo/web/index.html b/fleet-ops-demo/web/index.html new file mode 100644 index 0000000..eb2ce89 --- /dev/null +++ b/fleet-ops-demo/web/index.html @@ -0,0 +1,49 @@ + + + + + + Fleet Ops + + + + + + + +
    + + + Fleet Ops + + +
    + + +
    +
    + +
    +
    + +
    + + diff --git a/fleet-ops-demo/web/styles.css b/fleet-ops-demo/web/styles.css new file mode 100644 index 0000000..39447e3 --- /dev/null +++ b/fleet-ops-demo/web/styles.css @@ -0,0 +1,206 @@ +/* Fleet Ops. Road-sign colours with fixed meanings: green = allowed, red = denied, blue = links and + bulk checks. Everything else stays neutral. */ + +:root { + --asphalt: #1F2A30; + --concrete: #EEF1F3; + --paper: #FFFFFF; + --line: #CDD5DB; + --line-soft: #E3E8EB; + --ink: #1F2A30; + --muted: #5A6872; + --green: #00704A; + --green-tint: #E2F1EA; + --red: #BF1E2E; + --red-tint: #F9E4E6; + --blue: #1C4E8C; + --blue-tint: #E3EBF5; + --amber-ink: #7A5A00; + --amber-tint: #FFF3D1; + --font: 'Overpass', system-ui, -apple-system, 'Segoe UI', sans-serif; + --mono: 'Overpass Mono', ui-monospace, 'SF Mono', Menlo, monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; } +body { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100vh; + font-family: var(--font); + font-size: 15px; + line-height: 1.45; + color: var(--ink); + background: var(--paper); + -webkit-font-smoothing: antialiased; +} +h1, h2, h3, p { margin: 0; } +button, select { font: inherit; color: inherit; } +:focus-visible { outline: 3px solid var(--blue); outline-offset: 2px; } +a { color: var(--blue); } + +/* Top bar --------------------------------------------------------------------------------------- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 20px; + background: var(--asphalt); + color: #fff; +} +.brand { display: flex; align-items: center; gap: 10px; color: #fff; text-decoration: none; font-size: 19px; font-weight: 800; } +.shield { width: 30px; height: 32px; } +.nav { display: flex; gap: 4px; margin-right: auto; margin-left: 12px; } +.nav a { color: #B9C5CC; text-decoration: none; font-size: 14px; font-weight: 700; padding: 5px 10px; border-radius: 6px; } +.nav a:hover { color: #fff; } +.nav a[aria-current="page"] { color: #fff; background: rgba(255, 255, 255, 0.12); } +.switchers { display: flex; gap: 16px; flex-wrap: wrap; } +.switchers label { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #B9C5CC; } +.switchers select { + max-width: 340px; + padding: 5px 8px; + border: 0; + border-radius: 6px; + background: #fff; + color: var(--ink); + font-size: 14px; + font-weight: 600; +} + +/* Layout --------------------------------------------------------------------------------------- */ + +.layout { display: grid; grid-template-columns: minmax(0, 1fr) 460px; min-height: 0; } +.layout > * { overflow-y: auto; min-height: 0; } +main { padding: 24px 32px 48px; } + +/* Pages ---------------------------------------------------------------------------------------- */ + +.crumbs { font-size: 14px; color: var(--muted); margin-bottom: 6px; } +.crumbs a { text-decoration: none; } +h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.01em; } +.sub { margin-top: 4px; color: var(--muted); font-size: 15px; } +h2.section { margin: 28px 0 10px; font-size: 17px; font-weight: 800; } + +table.fleet { width: 100%; max-width: 860px; margin-top: 18px; border-collapse: collapse; font-size: 15px; } +.fleet th { text-align: left; font-size: 13px; font-weight: 700; color: var(--muted); padding: 8px 10px; border-bottom: 1px solid var(--line); } +.fleet td { padding: 10px; border-bottom: 1px solid var(--line-soft); } +.fleet td a { font-weight: 700; text-decoration: none; } +.fleet tr:hover td { background: var(--concrete); } + +.badge { display: inline-block; font-size: 13px; font-weight: 700; padding: 1px 8px; border-radius: 4px; } +.badge.active { color: var(--green); background: var(--green-tint); } +.badge.decommissioned { color: var(--muted); background: var(--concrete); } +.badge.road { color: var(--amber-ink); background: var(--amber-tint); } +.badge.depot { color: var(--blue); background: var(--blue-tint); } +.title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + +.facts { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 16px 0 0; font-size: 14px; } +.facts div { display: flex; flex-direction: column; } +.facts dt { color: var(--muted); font-size: 13px; } +.facts dd { margin: 0; font-weight: 600; } + +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 22px; } +.actions select, .grant select, input.text { padding: 7px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); font-size: 14px; } +.btn { + padding: 8px 14px; + border: 1.5px solid var(--asphalt); + border-radius: 6px; + background: var(--asphalt); + color: #fff; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} +.btn.secondary { background: var(--paper); color: var(--ink); border-color: var(--line); } +.btn.danger { background: var(--paper); color: var(--red); border-color: #E3A9AF; } +.btn:disabled { opacity: 0.55; cursor: progress; } +.none { color: var(--muted); font-size: 14px; } + +.flash { margin-top: 16px; padding: 10px 14px; border-left: 4px solid var(--green); background: var(--green-tint); font-size: 14px; max-width: 760px; } +.flash.error { border-left-color: var(--red); background: var(--red-tint); } + +ul.rows { list-style: none; margin: 0; padding: 0; max-width: 760px; border-top: 1px solid var(--line-soft); } +.rows li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--line-soft); font-size: 14px; } +.rows .meta { color: var(--muted); font-size: 13px; } +.grant { display: flex; gap: 10px; align-items: center; margin-top: 12px; flex-wrap: wrap; } +.field, .check { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--muted); } +.field select, .grant input.text { color: var(--ink); } +.row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; } +.row-actions select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); font-size: 14px; } + +ul.people { list-style: none; margin: 18px 0 0; padding: 0; max-width: 860px; display: flex; flex-direction: column; gap: 10px; } +.person { padding: 12px 14px; border: 1px solid var(--line-soft); border-radius: 6px; } +.person-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.certs { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; } +ul.roles { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 14px; } +.roles li { display: flex; align-items: center; gap: 8px; } +.btn.small { padding: 2px 8px; font-size: 12px; } +.activity { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line-soft); font-size: 14px; } +.activity ul { margin: 4px 0 0; padding-left: 18px; } +.meta { color: var(--muted); font-size: 13px; } + +.denied { max-width: 640px; margin-top: 8px; padding: 18px 20px; border-left: 6px solid var(--red); background: var(--red-tint); } +.denied h1 { font-size: 22px; } +.denied p { margin-top: 8px; } +.denied code { font-family: var(--mono); font-size: 13px; } + +/* PDP check log --------------------------------------------------------------------------------- */ + +.log { background: var(--concrete); border-left: 1px solid var(--line); padding: 16px 16px 40px; } +.log-head { display: flex; align-items: center; justify-content: space-between; } +.log-head h2 { font-size: 17px; font-weight: 800; } +.log-head button { padding: 3px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); font-size: 13px; cursor: pointer; } +.log-status { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 6px; font-size: 13px; color: var(--muted); } +.log-status span::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: #8C99A1; } +.log-status .ok::before { background: #2FA36F; } +.log-status .bad::before { background: var(--red); } +.log-sub { margin: 6px 0 12px; font-size: 13px; color: var(--muted); } + +.entries { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.entry { padding: 10px 12px; background: var(--paper); border: 1px solid var(--line-soft); border-left: 4px solid var(--blue); border-radius: 4px; font-size: 13px; } +.entry.allow { border-left-color: var(--green); } +.entry.deny { border-left-color: var(--red); } +.entry.fresh { animation: arrive 900ms ease-out; } +@keyframes arrive { from { background: var(--blue-tint); } to { background: var(--paper); } } +@media (prefers-reduced-motion: reduce) { .entry.fresh { animation: none; } } +.entry-head { display: flex; align-items: baseline; gap: 8px; } +.v { flex: none; font-size: 12px; font-weight: 800; padding: 0 6px; border-radius: 3px; } +.v.allow { color: #fff; background: var(--green); } +.v.deny { color: #fff; background: var(--red); } +.v.bulk { color: var(--blue); background: var(--blue-tint); } +.purpose { font-weight: 700; flex: 1; } +.when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +.call { display: block; margin-top: 6px; font-family: var(--mono); font-size: 12px; line-height: 1.5; color: #33424B; white-space: pre-wrap; word-break: break-word; } +.why { margin-top: 6px; } +.why.mismatch { color: var(--red); font-weight: 600; } +.entry details { margin-top: 6px; } +.entry summary { cursor: pointer; color: var(--blue); font-weight: 600; } +.calls { list-style: none; margin: 6px 0 0; padding: 0; font-family: var(--mono); font-size: 12px; } +.calls li { padding: 2px 0; } +.calls .allow .g { color: var(--green); font-weight: 800; } +.calls .deny .g { color: var(--red); font-weight: 800; } +.calls em { display: block; padding: 0 0 2px 18px; font-family: var(--font); font-style: normal; color: var(--red); font-weight: 600; } +.detail { margin-top: 6px; padding-left: 10px; border-left: 2px solid var(--line-soft); } +.detail ol, .detail ul { margin: 4px 0; padding-left: 18px; } +.detail .ok { color: var(--green); font-weight: 800; } +.detail .no { color: var(--red); font-weight: 800; } +pre { margin: 6px 0 0; padding: 8px 10px; max-height: 300px; overflow: auto; border-radius: 4px; background: #F4F6F8; border: 1px solid var(--line-soft); font-family: var(--mono); font-size: 11.5px; line-height: 1.45; } + +/* Narrow screens -------------------------------------------------------------------------------- */ + +@media (max-width: 1000px) { + body { display: block; height: auto; } + .layout { grid-template-columns: minmax(0, 1fr); } + .layout > * { overflow: visible; } + .log { border-left: 0; border-top: 1px solid var(--line); } + .topbar { flex-direction: column; align-items: flex-start; } +} +@media (max-width: 640px) { + main { padding: 18px 16px 32px; } + .switchers { flex-direction: column; width: 100%; } + .switchers label { flex-direction: column; align-items: flex-start; } + .switchers select { max-width: 100%; width: 100%; } + .fleet th:nth-child(3), .fleet td:nth-child(3) { display: none; } +}