GitHub
10/26/2026, 9:45 PMPOST /api/v1/users/{username}/add wrote permission rows for whatever environments the request listed. With environments: [""] and admin_access: true it created AdminLevel rows on environment "". Those rows pass the `AdminLevel`/`NoEnvironment` gate, so a user shown as non-admin could act as a global admin and bypass the last-admin guard. Names were also stored as given instead of UUIDs. The CLI DB-mode user add had the same gaps (empty --environment wrote junk rows, a name was stored raw).
Change
• API: before the user is created, each entry is trimmed, blank entries are skipped, and the rest are resolved by name or UUID and deduplicated to UUIDs. An unknown entry returns 404 environment not found: <id> and the user is not created. Admin requests are validated too. The admin: true path still grants all environments.
• `pkg/users`: GenPermissions and GenEnvUserAccess never emit a row for environment "". Authorization evaluation is unchanged.
• CLI DB mode: --environment is resolved to a UUID before the user is created (unknown is an error); without it a non-admin gets no permission rows.
• `apiclient.CreateUser`: omits environments when none is given.
• Docs: ARCHITECTURE.md documents that older deployments may hold rows on environment '', that an AdminLevel row there acts as a global admin, and three read-only SQL queries to find them (blank/NULL environment rows, the AdminLevel subset on non-admin users, rows whose environment is not a known UUID).
Security / operational impact
• Closes the shadow-admin path through user creation.
• No cleanup of existing rows. Operators should run the documented queries on existing deployments and review any hits.
• Compatibility: an unknown environment used to return 200 and store junk; it is now 404. Clients sending [""] or no environments behave as before. Clients sending names now get UUIDs stored. "User already exists" is still a 500, unchanged.
• The web UI sends environments: [] and is unaffected.
Validation
• go test ./cmd/api/... ./cmd/cli/... ./pkg/... -count=1, go vet ./..., gofmt, golangci-lint, make openapi-check (the 404 was already in the annotations).
• New tests cover UUID and name input, unknown entry with no user created, [""] creating no rows (including with admin_access, where the user no longer passes the global gate), duplicates, the unchanged 403/exists/admin paths, CLI by-name and no--e cases, DB/API parity, and the generator skipping "".
• The shadow-admin case was reproduced before the fix. TestAPISelfDemoteRefusedForNonGlobalAdminActor still passes.
jmpsec/osctrlGitHub
10/26/2026, 9:53 PM