<#1149 Granular RBAC part 4: sensitive carve paths...
# osctrl
g
#1149 Granular RBAC part 4: sensitive carve paths Pull request opened by javuto Granular RBAC part 4: sensitive carve paths Problem With parts 1–3 merged, query and carve tiers control which nodes a user can reach, and sensitive nodes are reserved for the full tier. A responder with carve access could still copy any file off a node they could reach: key material, credential stores, the Windows SAM hive. The spec reserves sensitive paths for full carve tier (#704). Earlier parts: global roles #1144, tiers #1146, sensitive nodes #1147. What changes Per-environment list. A new table,
env_sensitive_paths
(environment ID, path, created_by), holds normalized path prefixes, unique per environment. Environment admins manage it: • `GET /api/v1/environments/sensitive-paths/{env}`: environment admin, superuser or read-only admin. •
POST /api/v1/environments/sensitive-paths/{env}
with `{"path": "..."}`: environment admin or superuser. • Returns 400 for relative, rejected or overlong paths (more than 512 bytes after normalization). • `DELETE /api/v1/environments/sensitive-paths/{env}/{id}`: environment admin or superuser. Returns 404 for another environment's row. Adding and removing paths are audited. Deleting an environment removes its list. Matcher.
environments.NormalizePath
and
environments.PathTouches
are pure functions that only look at the path text. • Normalization: Unicode NFC plus case folding (always case-insensitive),
\
treated as
/
,
path.Clean
, then trailing dots and spaces trimmed from each segment. • Fails closed on: •
..
segments, and segments made only of dots or spaces; • UNC and device paths (a leading
//
); • NTFS alternate data streams (
:
after the drive letter); • 8.3 short names (
~
) and NUL bytes. • Touches a prefix when the path is the prefix, lies under it, or is a parent of it, so carving
/etc
is refused when
/etc/shadow
is listed. • Drives: paths with and without a drive letter match each other in both directions. • Roots and devices: a root touches every prefix on its volume.
/dev
and
/proc
touch whenever the list is non-empty. • macOS: paths under
/private/
and
/System/Volumes/Data/
are also checked with that alias stripped. Carve run. Below full carve tier,
POST /carves/{env}
refuses a path that touches the list, with 403
path is sensitive
. • It checks the carve's environment and every environment it targets, using the caller's carve tier in each. • A malformed path is refused with 400 before any tier check. • Nothing is created when the carve is refused. Carve reads. Below full carve tier, carves whose path touches the current list are hidden. This includes carves taken before the path was listed. • Carve list and carve queries leave them out. Search and totals are worked out after hiding, so searching can't reveal hidden paths. • Carve show and archive return the same 404 as a carve that doesn't exist. • Carved files with a listed path are dropped. • On the query detail, results and CSV routes, a carve-type query needs carve read access and a path that isn't hidden. Otherwise it returns
query not found
, exactly like a missing query. Pending carves. Adding a path cancels pending carves of it on that environment's nodes, when the creator's carve tier there is below full. Env admins' console carves are kept. Security and operational impact • The list is empty on upgrade day, so nobody loses access until a path is added. • Enforced in osctrl-api only. osctrl-tls is not changed. • Schema: one new table with a unique index on
(environment_id, path)
, where
path
is
varchar(512)
. That fits MySQL's utf8mb4 key limit. • API contract change: showing a query that doesn't exist now returns 404 instead of an empty 200.
apiclient.GetQuery
now returns an error on a miss. • Console and file explorer are unchanged; they are env-admin only, which is full tier. • MCP already refuses carves, and its read tools go through the gated handlers. Known limits • Text-only matching: symlinks, hard links, junctions and firmlinks other than the two macOS aliases are not resolved. • Over-blocking: case is always ignored, so Linux is over-blocked. Any path containing
..
,
~
or a non-drive
:
is refused. • Cross-environment carves: a full-tier carve that targeted another environment's sensitive path still shows its path and creator in the source environment's carve list. Its files never leak. • CLI: the CLI's direct-database carve path is not checked, and the CLI can't manage the list yet. UI comes in part 5. • Cancelled carves: they stay active, with expected results that never arrive, the same as cancellations in parts 2 and 3. • Follow-up: • carving a macOS alias parent itself (
/private
,
/System/Volumes/Data
) is not yet treated as touching; • a huge
?page=
value panics the carve list request for users below full tier (Go's HTTP server recovers it). Validation •
go build ./...
and
go test ./...
pass. • New tests cover: • normalization and touch rules: every Windows, POSIX, Unicode and alias case from three adversarial review rounds; • list CRUD, dedupe, the length limit, the JSON shape, and cleanup when an environment is deleted; • carve run refusal, including cross-environment targets and malformed paths; • hidden carves on list, search, show, archive and carved files, and on the query show, results and CSV routes, with byte-identical 404s; • the carve tier deciding, not the query tier; • the management API's permissions, validation, cross-environment delete and audit; • pending cancellation, including keeping env admins' console carves. •
make openapi-check
passes. •
cd frontend && npm run check
passes. • `golangci-lint`: no new findings. •
git diff --stat -- cmd/tls
is empty. • Not run: Playwright and in-browser checks. There is no visible UI change, so there are no screenshots. jmpsec/osctrl