#UniCAS Tenant Debug Tools — Requirements
Status: requirements baseline v0.1 (design discussion session, 2026-08).
unicas-tenant is the tenant-facing debug tooling for the CAS data plane
(/stacks/{stackId}/tenants/{tenantId}/cas/...). It lets a tenant user query
and mutate CAS data — node content/metadata, leases, Root Refs, GC, usage — as
a low-level debugging and migration tool. CLI first; a WebUI sharing the same
session store follows later. It is deliberately not an end-user product.
#Positioning
- Audience: tenant users (not stack administrators; no control-plane access).
- Purpose: debug query/mutation of tenant data-plane state.
- Nature: low-level tool; safety rails (dry-run, confirm) matter more than UX.
- Form:
unicas-tenantCLI now; WebUI later, sharing the same session store.
#Identity and login model
- The tool never holds issuer private keys and never embeds
client_id/client_secret. - Login unit is the (stackId, tenantId) pair; each entry is logged in and
cached independently; several entries can coexist and the active one is
switchable.
tenantIdis not user input: it is determined by the stack-side OAuth decision from the authenticated identity (see login flow). - Login flow per entry:
- Resolve the stack's issuer (see below).
- RFC 8414 discovery at the issuer.
- RFC 7591 dynamic client registration — public client,
token_endpoint_auth_method=none, PKCE,response_type=code. - Browser authorization on the stack's own login page.
- Code exchange for a capability JWT (claims carry
tenantId+permissions); thetenantIdclaim is authoritative — the provider derives it from the user's identity, never from a client-supplied parameter. - Create/refresh the store entry keyed by
(stackId, claims.tenantId); cache the JWT under~/.unicas(0600), aligned with the admin-cli layout so the later WebUI can reuse the store.
- There is no tenant selector parameter in v1. A user's account maps to the tenant the stack's OAuth decides; accessing a different tenant of the same stack means logging in with a different account (logout first, then re-authorize).
- The stack's OIDC must accept arbitrary dynamically registered clients. The tenant WebUI/CLI is an ordinary client: no first-party whitelist, no pre-registered client credentials.
#Issuer resolution
The tool has no control-plane access, so the stack's issuer URL comes from a new platform public discovery endpoint:
GET /stacks/{stackId}/.well-known/openid-configuration
-> { issuer, audience }
Unauthenticated; served by the edge using the existing authority registry
(issuer → stackId mapping, ~30s cache). No tenant enumeration is provided: the
control plane has no tenant registry (tenants are implicit stack-app
partitions); the user supplies tenantId from their own application or admin.
#Capability and security
- Lifetime: discovered Stack OAuth issuers carry UniCAS's fixed server
policy lifetime (30 minutes;
OAUTH_CAPABILITY_MAX_LIFETIME_SECONDS) with the audience derived from the stack's canonical resource. The verifier enforces the authority'scapabilityMaxLifetimeSecondsfrom the registry. The provider contract tells stack issuers that signing beyond the bound is rejected. - Refresh: when the stack OIDC issues a refresh token, the tool caches it and auto-refreshes (automation-friendly). Refresh-token lifetime/rotation policy is part of the provider contract.
- Permission posture: full-permission login (
cas:read+cas:write+cas:managein one login). Consequence: a stolen long-livedcas:managecapability is administrator-equivalent. Accepted risk; mitigations are the per-stack lifetime cap, 0600 storage, and client-side safety rails (dry-run,--confirm).sessions:*permissions are app-layer and out of scope.
#Local session store and cache
Folder per (stack, tenant) under ~/.unicas, aligned with the admin-cli layout
so the later WebUI reuses the same store:
~/.unicas/
├── session.json # admin-cli session (cookie + CSRF)
└── tenants/
├── active.json # global active entry {stackId, tenantId}
└── <stackId encoded>/
└── <tenantId encoded>/
├── session.json # login state (0600)
└── cas/ # node cache (0700), hash-prefix sharded
├── ab/ # first two hex chars of the hash
│ ├── cdef… # node content (binary)
│ └── cdef….meta.json # metadata sidecar: size/contentType/refs
└── …
- Per-entry
session.json:{ stackId, tenantId, issuer, audience, clientId, token, expiresAt, refreshToken?, permissions[], subject, loggedInAt }. Refresh tokens rotate in place with an atomic tmp+rename write. A failed refresh marks the entryexpired(kept for inspection, never auto-cleared). - Cache semantics: content-addressed, so the hash IS the content check —
no staleness/invalidation problem. A cached node is trusted as-is; the only
remote change is GC deleting the origin (the local copy is kept, which is
what a debug tool wants). No eviction in v1;
cache status/cache clear. - Cache policy — node granularity (the disk cache implements
CasNodeCachefrom@unicas/tenant-client): full node reads (range === undefined) populate the cache on miss; partial reads (--range) serve from a cached full copy or pass through without populating; metadata (incl.refs) is cached so subgraphs can be walked offline.leaseNodewrite-through is deferred. - Range contract: the
CasNodeCache.read(key, range, load)interface comment in tenant-client is updated to state thatrange === undefinedmeans a full read (populate) and a present range means a partial read (serve or bypass); the policy lives in the cache implementation. - Blob layer:
createCasBlobClient(cas, options)is cache-agnostic and takes the tenant client as injected; its normal path reads child nodes in full, so blob reads populate the node cache automatically. - Permissions: tenant directories 0700,
session.json0600; cached content may be sensitive and inherits the current user's permissions.
#Data-plane surface (CLI command draft)
| Group | Commands |
|---|---|
| Session | login <stack> [--issuer URL], logout <stack> <tenant>|--all, list, status, use <stack> <tenant> |
| Read | node get <hash> [--range N[:L]] [--out file], node meta <hash>, walk <hash> [--depth N] |
| Admin | usage, gc [--max-nodes N] [-y|--yes], root-refs update <refDomain> <hash> <delta> [-y|--yes], lease <hash> [--source file] [--duration-ms N] |
Conventions aligned with the admin CLI: JSON on stdout, diagnostics on stderr,
exit codes 0/1/2, idempotency keys where applicable, CAS base URL from
UNICAS_SERVER_URL. Destructive operations (gc, root-refs update) confirm
interactively on a TTY, are skipped without a TTY, and -y/--yes skips the
prompt. No default dry-run for gc. node get never prints binary content to
stdout by default: it prints a JSON summary, --out <file> writes content,
--out - writes raw bytes.
#Platform deliverables and scope
unicas-tenantCLI (new package underpackages/, e.g.@unicas/tenant-cli).- Public stack discovery endpoint on the edge (contract above).
- Provider contract documentation: discovery-document shape,
authorize/token semantics, scopes, capability claims, error codes,
refresh-token policy — see
docs/cas-tenant-oidc-provider-contract.md. Stack applications implement their own OIDC provider; the platform ships the contract only (no reference provider in v1).
#Explicitly out of v1
- MCP server (revisit together with the WebUI).
- WebUI (but the store layout is designed for reuse).
- Tenant enumeration,
sessions:*permissions, control-plane/admin operations.
#Open design questions
- Exact discovery endpoint contract (response shape, caching, error semantics, edge routing).
- CLI UX details (active-entry selection, flag/env precedence).
- Error taxonomy for login and data-plane failures.
#Decision log
| # | Decision | Date |
|---|---|---|
| 1 | Tool is tenant-facing; never holds issuer keys; the stack provides the OAuth authorization URL and issues the tenant JWT | 2026-08 |
| 2 | JWT issuance must accept dynamically registered clients; no first-party tool clients | 2026-08 |
| 3 | Login unit is (stack, tenant) as peers | 2026-08 |
| 4 | CLI first, WebUI later (shared store) | 2026-08 |
| 5 | Issuer resolution via a public edge discovery endpoint | 2026-08 |
| 6 | Lifetime: default 8h, per-stack cap up to 7d, optional refresh token | 2026-08 |
| 7 | Full-permission login (cas:manage in one login) |
2026-08 |
| 8 | v1: no MCP | 2026-08 |
| 9 | Remove cas_stack_issuer.status — dead over-design (no write path existed; verifier issuer_disabled fail-closed was unreachable) |
2026-08 |
| 10 | Store: folder per (stack, tenant) — session.json + a per-tenant node cache (cas/, hash-prefix sharded, metadata sidecars) |
2026-08 |
| 11 | Cache at node granularity (CasNodeCache): full reads populate, partial reads serve-or-bypass, metadata cached for offline walk; blob layer benefits automatically; leaseNode write-through deferred |
2026-08 |
| 12 | No tenant selector parameter: tenantId is decided by the stack-side OAuth from the authenticated identity; multi-tenant access via separate accounts (logout → re-authorize) |
2026-08 |
| 13 | Destructive ops confirm interactively (TTY), refuse without TTY, -y/--yes skips; no default dry-run for gc; node get never prints binary by default |
2026-08 |
| 14 | Provider contract: scope vocabulary cas:read/cas:write/cas:manage (tool requests cas:manage, provider may downgrade); refresh token one-time rotation, ≤ 7d default; jwks_uri is the signing-key source |
2026-08 |
| 15 | Lifetime caps implemented: MaximumCapabilityLifetimeSeconds → 7d; per-stack capabilityMaxLifetimeSeconds (default 28800) in issuer set, webui, cli; CAS verifier enforces the per-stack cap from the registry |
2026-08 |