This document defines the Picomesh security design to port from yaapp. It is a target design, not a statement that the current C implementation already matches it.
The core idea: authentication/authorization is a core picomesh mechanism run
at the service-invoke boundary, selected per node by config — not logic baked
into any frontend. Every protocol carries headers, so a frontend's only auth job
is to fold its wire headers into yheaders and invoke the target method with
them; the core then runs the configured authn chain + authorizer off those
headers before the method body executes:
request (any frontend: yhttp / yrpc / msgpack)
-> frontend folds protocol headers into yheaders, invokes the method
-> CORE auth-at-invoke (only when this node's config enables it):
authenticator chain -> JWT or anonymous
authorizer(endpoint, args/kwargs, JWT)
-> verified auth context (JWT claims) replaces the credential in yheaders
-> the resolved service method runs
Because the mechanism is core and reads yheaders (which every protocol
carries), the same auth works under any frontend with zero per-frontend auth
code. Which authenticators/authorizer a node runs — and therefore whether it is
an auth boundary at all — is that node's own config.
Services such as session, token_issuer, accounts, password_authn, and
personal_access_tokens are mesh plugins/services. They can run in the same
node as the boundary or in another node reached through the mesh. The auth
mechanism must call them through configured service paths; it must not hardcode
their storage details.
In yaapp, auth is split like this:
| Concern | Yaapp location | Role |
|---|---|---|
| Per-request frontend pipeline | src/yaapp/frontends/fastapi/plugin.py, src/yaapp/frontends/yttp/plugin.py |
Runs authn/authz before service invocation |
| Authenticator framework | src/yaapp/authenticators/base.py, registry.py |
Builds and runs ordered authenticators from config |
| Built-in authenticators | src/yaapp/authenticators/session_cookie/, bearer_jwt_token/, bearer_opaque_token/ |
Convert cookie/bearer credentials to a JWT |
| Authorizer framework | src/yaapp/authorizers/ |
Builds and runs an authorizer from config |
| Policy authorizer | src/yaapp/authorizers/policy/ |
Default-deny endpoint policy, role ladder, account resolution |
| JWT verifier helper | src/yaapp/lib/jwt_verifier.py |
Fetches signing key from token_issuer, verifies JWT |
| Credential services | session, token_issuer, accounts, password_authn, personal_access_tokens plugins |
Own sessions, tokens, credentials, groups |
Yaapp's current policy authorizer is framework code, not a remote authz
service call on every request. It was ported from an older authz plugin.
Picomesh may keep that framework-authorizer shape or deliberately implement an
authz plugin/service, but the choice must be explicit.
In Picomesh, "plugin" means a mesh feature/service that can be activated in a specific node or run in a remote node. Security must preserve that boundary.
Orchestration is a CORE step at the invoke boundary, not a frontend responsibility. The frontend only carries headers and invokes; the core, when this node's config enables auth, does:
- read the credential from
yheaders(the frontend already folded the wire headers in — cookie / bearer / forwarded header); - run the configured authenticator chain;
- receive or verify an access JWT;
- run the configured authorizer;
- invoke the target service method only after allow;
- replace the credential in
yheaderswith the verified JWT/auth context and forward only that downstream.
Neither the frontend nor the core auth step may know how PATs are stored, how session rows are laid out, or how account memberships are stored. Those are plugin/service contracts the configured authenticators reach over RPC.
Low-level library code may exist for crypto/JWT primitives, but it must stay boring:
- base64url;
- HMAC/JWT encode/decode;
- constant-time compare;
- claims parsing;
- maybe a helper to fetch/verify signing material.
It must not contain policy decisions such as "fall back to uid if JWT verification fails".
A deployment may have several frontends: yhttp, yrpc, future bridge
frontends, and service-console frontends. Whether a node is an auth boundary
is decided by that node's config, not by which frontend it runs. The
"gateway" is simply a node whose config declares opaque-resolving authenticators
plus a policy authorizer; the same yhttp binary on another node with no
security block is a dumb transport carrier. One core mechanism, three config
shapes:
- boundary — opaque-resolving authenticators (
session_cookie,bearer_opaque_token) + an authorizer: resolves the external credential to a JWT, scrubs the opaque credential, authorizes, forwards JWT-only; - internal verifier — a
bearer_jwt_tokenauthenticator only: verifies the JWT already in the prefix and rejects if absent/invalid; - trusting internal — no
securityblock: runs no auth-at-invoke and trusts the upstream-stamped context (safe only behind a boundary).
Typical Picoforge shape:
browser or API client (presents an OPAQUE cookie/bearer)
-> Picoforge webapp or direct client
-> a node configured as a BOUNDARY
core auth-at-invoke:
1. resolve cookie/bearer -> JWT (or anonymous), SCRUB the opaque
2. authorize endpoint + arguments
3. invoke active local or remote service with JWT-only context
-> backend service nodes (see only the internal JWT)
Internally only the JWT travels. The opaque cookie/bearer crosses only the
external edge into the boundary node and is scrubbed there; every hop after that
carries the verified JWT in yheaders, never the opaque secret. Backend nodes
without their own auth config are safe only behind a boundary; their RPC surface
must not be reachable by untrusted clients. The external edge stays opaque-only:
a boundary must reject a client-presented raw JWT, so bearer_jwt_token is an
internal-verification authenticator, never enabled on the external edge.
When a node has security configured, the core gates every service invocation at
the invoke boundary — independent of frontend. The frontend has already folded
its wire headers into yheaders and called the method; the core auth step runs
before the method body. Surfaces gated this way include /_rpc, /_describe,
/_describe_tree, and any yrpc/msgpack service-call entrypoint. HTML/static/debug
routes may pass through only if they do not invoke backend service methods.
For each service invocation:
invoke(method, yheaders, args) (yheaders already carries the credential)
-> run authenticator chain, reading the credential from yheaders
no credential shape matched: jwt = none
credential shape matched and valid: jwt = verified JWT
credential shape matched and invalid: fail 401
-> run authorizer(endpoint, args/kwargs, jwt)
allowed: continue
denied: fail 403
-> replace the credential in yheaders with the verified JWT/auth context
-> run the resolved active service method
The failure rule is security-critical: if a credential of a known shape is present but invalid, the chain stops with 401. It must not fall through to a weaker scheme or to a raw uid header.
The endpoint is the fully qualified service method name, for example:
git_repo.git_repo.make
git_repo.git_repo.put_file
accounts.accounts.get_user
mesh.mesh.reconcile
Authentication answers "who is this caller?" It does not decide whether the caller may perform the requested operation.
Authenticators are configured by type. Each authenticator owns only one
credential shape, and reads it from yheaders — the frontend has already folded
its protocol's cookie / Authorization / forwarded headers into the bag, so an
authenticator never parses a wire format and behaves identically under any
frontend.
| Type | Credential read (from yheaders) |
How it yields a JWT |
|---|---|---|
session_cookie |
configured cookie or same-named forwarded header | calls configured lookup RPC, usually session.session.jwt, and verifies the returned JWT |
bearer_jwt_token |
Authorization: Bearer <jwt> |
verifies the JWT signature and expiry directly |
bearer_opaque_token |
Authorization: Bearer <prefix>... |
calls configured lookup RPC, such as PAT or runner lookup, and verifies the returned JWT |
Example shape:
security:
authenticators:
- type: session_cookie
cookie: picomesh-sid
header: picomesh-sid
lookup: session.session.jwt
- type: bearer_jwt_token
header: Authorization
reject_prefixes: ["pat_", "rnr_"]
- type: bearer_opaque_token
prefix: "pat_"
lookup: personal_access_tokens.personal_access_tokens.lookup
- type: bearer_opaque_token
prefix: "rnr_"
lookup: runner_agent.runner_agent.lookup_tokenThe opaque-token authenticator must not parse PAT ids or runner ids itself. It passes the opaque token to the configured lookup service. The lookup service returns a JWT or a payload containing a JWT. The authenticator verifies that JWT before forwarding it.
token_issuer is a plugin/service. It owns framework JWT issuance and refresh
tokens. Authn plugins verify credentials; they do not mint framework
authorization tokens themselves.
Login flow:
token_issuer.login(method, credentials)
-> call <method>_authn.verify(credentials)
-> receive identity: user id, username, optional email
-> load groups from accounts/users service
-> mint short-lived access JWT
-> mint longer-lived opaque refresh token
-> return token pair and public user metadata
The access JWT should contain at least:
{
"iss": "picomesh",
"sub": "user-id",
"username": "alice",
"groups": ["site:owner", "alice:owner", "team:developer"],
"iat": 1710000000,
"exp": 1710000900,
"jti": "unique-token-id"
}The signing key must come from token_issuer or an explicitly configured key
source. HS256 is acceptable only inside a trusted mesh because verifiers need
the symmetric secret. For untrusted edges, prefer an asymmetric scheme such as
RS256 or EdDSA so verifiers only need public key material.
Browser sessions use opaque cookies. In the normal cookie flow the browser must not receive the internal JWT.
The session plugin/service stores:
session_id -> access JWT, refresh token, user metadata, expiry metadata
Browser login should be composed by the session service, matching yaapp:
POST login request
-> session.session.start(method="password", credentials)
-> session.start calls token_issuer.login(...)
-> session.start stores access JWT under a fresh opaque sid
-> response sets HttpOnly picomesh-sid cookie
Later requests:
browser sends picomesh-sid cookie
-> session_cookie authenticator calls session.session.jwt(session_id)
-> jwt() returns the stored access JWT if the session exists
-> authenticator verifies the JWT (signature + expiry)
-> authorizer decides endpoint access
Session ids are bearer secrets. They must be high-entropy random values and must never be derived from user ids, counters, clocks, or storage row numbers.
Because the sid is a bearer secret, it never appears in a listing.
session.session.list / list_all return only non-secret metadata
(uid, created_at) — never the sid, the stored access JWT, or the refresh
token. The same rule holds for every credential store: token_issuer.list
returns uid/username/created_at but never the refresh token, and a
runner/PAT list must never return its opaque token. One-time token material is
handed out only at issuance, not by a list call. A caller with list access must
not be able to harvest a live credential.
Session rows should support:
- idle timeout, extended on activity;
- absolute max lifetime;
- logout deletion;
- optional cookie/session rotation on login, privilege elevation, or suspicious activity.
The cookie value does not need to rotate on every request. Rotating every request can create races with parallel browser requests.
This is the current implementation, not the target design:
- Session state — the
sessionservice stores, keyed by the opaque sid: the access JWT, refresh token, and user/expiry metadata. The sid is the only thing the browser holds. - Refresh tokens — stored by the
session/token_issuerpath, never returned by a list call. - Runner tokens —
runner_agentstorestok:<sha256hex> -> runner_idinsharded_storage; the raw token is shown once at create and never persisted in cleartext, only its hash. - Personal access tokens —
personal_access_tokensholds token records in memory today (not yet durable opaque-by-hash storage; see the gap note below). - Access JWTs — stateless and short-lived; minted on demand, never listed.
In every case the durable record holds at most a HASH of the bearer secret (or, for the session JWT, an internal credential that never leaves the mesh); the raw bearer value is handed to the client only at issuance.
A user's numeric UID is the shard key for their data and an authorization identity across services, so its allocation rules are load-bearing.
- Allocation. UIDs are ASSIGNED, not derived from the username. The
accountsservice allocates them from a single global AUTOINCREMENT sequence (uid_seq, pinned to one shard) and returnsACCOUNTS_UID_ALLOC_BASE + rowid. The base lifts assigned ids clear of the dense low integers. The gateway callsaccounts.allocate_uidat the very start of/register(and github_authn does the same for a brand-new OAuth identity), BEFORE writing the credential and BEFORE claiming the username. - Reuse policy. UIDs are NEVER reused. AUTOINCREMENT guarantees the sequence
is monotonic even across row deletion, and
allocate_uidadditionally refuses any id that already has ausersrow. This is what makes a partially-failed registration safe: a crash after allocation only ever strands a uid no future user will be handed, so a stranded credential can never be inherited. - Resolution. Username → uid is resolved authoritatively by
accounts.uid_for_username(reads theusernameslookup row for a CONFIRMED account; 0 if unknown)./login, the webapp member-grant, and github_authn all resolve through it instead of recomputing a hash. Username UNIQUENESS is enforced transactionally by theusernamestable's PRIMARY KEY plus INSERT-OR-IGNORE winner election, independent of the uid. - Deletion / deactivation. Removing an account frees the username claim but
does NOT free the uid for reuse — the
uid_seqhigh-water mark only advances. - Migration of legacy hash-derived users. Older deployments stored
uid = fnv1a32(username)inusers/usernames/password_authn/namespace_members/sessions/ the github bindings.uid_for_usernamereads whatever uid theusernamesrow records, so those accounts keep working unchanged — they simply resolve to their original hash-uid. New registrations get assigned uids. TheACCOUNTS_UID_ALLOC_BASEoffset plus theusers-row collision check inallocate_uidmake a new assigned uid clashing with a surviving legacy hash-uid astronomically unlikely and, if it ever happened, safe (the clash is detected and the next id is used, never an overwrite).
| Route(s) | Auth mechanism |
|---|---|
POST /_rpc |
configured authenticator chain + policy authorizer when a security block is present; on an unsecured/collocated node, sid → stored access JWT forwarded downstream |
GET /_describe, /_describe_tree |
no auth (topology discovery; metadata only) |
POST /_whoami |
sid → verified JWT claims (uid/username/groups); returns metadata only, never a JWT |
POST /login, /register |
credential exchange; mints a session, returns an opaque picomesh-sid cookie |
POST /auth/github/callback |
OAuth relay; same session-minting outcome |
POST /logout |
clears the session/cookies |
POST /repos/new, /admin/tokens/mint_pat |
resolve_authctx() → verified access JWT; admin/namespace gate from verified claims |
GET HTML pages |
none — the gateway is API-only and 404s every GET HTML route (the webapp owns pages) |
No mutation route trusts a raw uid or the picomesh-uname cookie for
authorization: every authenticated mutation derives its actor from a
signature-and-expiry-verified JWT, and a config READ failure for the security
block fails the gateway closed (every request 500s) rather than silently
disabling auth.
Some methods consume credentials as their arguments:
session.session.jwt(session_id)exchanges an opaque session id for the stored access JWT (thesession_cookieauthenticator'slookup).token_issuer.token_issuer.refresh(refresh_token)exchanges a refresh token for a fresh access JWT and rotated refresh token.personal_access_tokens.personal_access_tokens.lookup(token)exchanges an opaque PAT for a JWT.runner_agent.runner_agent.lookup_token(token)exchanges an opaque runner token for a runner JWT.
These are not open data endpoints. They exist so authenticators can turn an otherwise unauthenticated request into a JWT-bearing request.
Expose them carefully. If they are reachable through public /_rpc, then any
holder of the opaque secret can retrieve a JWT. Prefer one of these designs:
- hide credential-exchange methods from normal public service invocation;
- expose them only on an internal frontend used by the authenticator pipeline;
- allow public invocation only if the deployment explicitly accepts that behavior.
Picoforge authorization should be role-based over namespaces, not only owner-id checks on individual repos. The GitLab-compatible model is:
namespace:
id
parent_id nullable
slug
kind: user | group
repo:
id
namespace_id
name
namespace_member:
namespace_id
uid
role: guest | reporter | developer | maintainer | owner
A namespace is the ownership container for repos. A user gets a personal namespace when the account is created:
alice/demo
Groups are also namespaces:
acme/api
Group namespaces may contain sub-namespaces:
acme/platform/api
acme/platform/backend/auth-service
So the repo's full path is:
<namespace-path>/<repo-name>
GitHub-style ownership is a constrained case of the same model: repos usually
attach only to a root user/org namespace (owner/repo), while teams provide
permission grouping. GitLab-style ownership allows nested group namespaces in
the repo path. Both shapes can use the same RBAC evaluator.
Role inheritance follows the namespace tree. To authorize an action on a repo, resolve the repo's namespace, walk from that namespace to its parents, and compute the caller's effective role as the highest applicable membership. A direct membership on a child namespace may grant a more specific role than an ancestor. The first implementation can use monotonic max-role semantics; if deny rules or inheritance locks are needed later, they must be added explicitly.
effective_role(uid, namespace):
max(
direct membership on namespace,
inherited membership on parent namespaces,
explicit site bypass when configured
)
Examples:
alice owns namespace alice
alice/demo requires developer to push
alice has alice:owner
owner >= developer
=> allowed
bob is acme:developer
acme/platform/api requires reporter to read
developer >= reporter
=> allowed through inherited acme membership
carol has no role on acme or acme/platform
acme/platform/api requires reporter to read
=> denied
Site-level administration is separate from namespace membership. A deployment
may choose to let site:maintainer or site:owner bypass normal namespace
checks, but that bypass must be explicit in the authorizer and should be used
only for administrative operations.
Authorization answers: "may this caller invoke this endpoint with these arguments?"
Picomesh runs a config-selected authorizer at the core invoke boundary (per
node, from that node's security config). The yaapp-compatible default is a
policy authorizer keyed by endpoint name. Endpoints absent from policy are
denied by default.
Policy example:
security:
authorizer:
type: policy
policy:
_describe: anonymous
_describe_tree: anonymous
token_issuer.token_issuer.login: anonymous
token_issuer.token_issuer.refresh: anonymous
session.session.start: anonymous
accounts.accounts.get_user: authenticated
accounts.accounts.list_users:
required_role: owner
account_from: site
# Repo content reads require at least reporter on the repo namespace.
# The authorizer should resolve repo_id -> namespace_id -> namespace path
# and compare the caller's effective namespace role.
git_repo.git_repo.read_tree:
required_role: reporter
resource_from: "{kwargs.repo_id}"
role_scope: repo_namespace
# Repo writes require at least developer on the repo namespace.
git_repo.git_repo.put_file:
required_role: developer
resource_from: "{kwargs.repo_id}"
role_scope: repo_namespace
mesh.mesh.reconcile:
required_role: owner
account_from: siteRule evaluation:
anonymous: allow without a JWT.- No JWT and rule is not anonymous: deny.
- Verify JWT signature and expiry.
authenticated: allow any valid JWT.- Role-gated rule: resolve the target namespace/resource, compute the caller's effective role there, and compare it with the required role.
Roles use a monotonic ladder:
guest < reporter < developer < maintainer < owner
The JWT groups claim stores namespace memberships as strings:
<namespace-path>:<role>
Examples:
site:owner
alice:owner
acme:maintainer
acme/platform:developer
Site-level administrators can be represented by site:maintainer or
site:owner, depending on deployment policy. The site-level bypass must be
explicit in the authorizer.
The yaapp policy authorizer supports account resolution against kwargs. Picoforge
should generalize this to resource resolution, because most repo APIs take a
repo_id, not a namespace path:
| Form | Resolves to |
|---|---|
site |
literal site |
{kwargs.name} |
the whole value of kwargs["name"] |
{kwargs.name:account} |
namespace/account part before / |
{kwargs.repo_id} + role_scope: repo_namespace |
repo's owning namespace |
Picomesh may also support positional args while its JSON API remains
positional, but the design target should preserve the yaapp-style named
argument form as the cleaner policy language. The final policy language should
name resources precisely enough that endpoint policy does not have to fall back
to ad hoc service-local owner checks.
Groups are minted into the JWT at login and refresh time. The token issuer must call the canonical accounts/users service to load namespace memberships.
This implies point-in-time claims:
- role changes take effect when the user logs in again or refreshes the access token;
- short access-token TTL limits role staleness;
- deleting a session or refresh token is immediate for future exchanges;
- already-issued access JWTs remain valid until expiry unless JWT revocation is added.
For Picoforge, the current accounts plugin is only a partial user/namespace
authority. The target accounts/users service must own users, namespaces,
namespace memberships, roles, and credential links.
The current implementation only partially matches this design.
Known mismatches:
-
src/picomesh/frontends/yhttp/frontend.cbuilds and runs the authenticator chain + authorizer (gateway_security, the auth block inroute_json_rpc) inside the yhttp frontend, and legacy mutation routes resolve identity themselves. The target moves that mechanism into the CORE invoke boundary, config-selected per node, so every frontend gets it by carrying headers and invoking — no per-frontend auth code. -
The yhttp authenticator code knows PAT and runner internals instead of using generic
bearer_opaque_tokenentries with configuredlookupservice paths. -
src/picomesh/security/secret.cmust remain low-level helper code. It now fails closed on absent/invalid JWTs; keep it that way and do not reintroduce anyyheaders["uid"]fallback or other policy decision there. -
Browser login/session composition is currently partly in gateway routes. The yaapp shape puts server-side login composition in
session.start, which callstoken_issuer.login, stores the JWT, and returns only the opaque cookie. -
Session rows do not yet enforce the full idle timeout plus absolute lifetime model on every gateway mutation path.
-
The non-login gateway mutation routes now resolve identity through the SAME verified exchange as
/_rpc—resolve_authctx()turns the opaque session token into the user's stored access JWT and verifies its signature + expiry before constructing the caller auth context — so an expired or invalid session JWT cannot authorize a mutation, and the admin / writable-namespace gates are derived from the verified JWT claims, never the forgeablepicomesh-unamecookie. The baresession.lookup -> uidhelper (uid_for_token) survives only on the unsecured/collocated node path as an INFORMATIONAL trace-line uid: that request goes downstream with no JWT, and backends — which derive identity solely from the JWT — reject any authenticated mutation it attempts. See the route→mechanism map below. -
The policy language currently supports only a subset of yaapp account resolution.
-
Namespace RBAC is implemented (issue #30). The
accountsservice owns the canonical namespace tree (namespaces) and membership authority (namespace_members) with the GitLab role ladder; a personal namespace is created per user at register, group and nested sub-namespaces viaaccounts.ns_create, and grants viaaccounts.ns_add_member. The token issuer mints memberships into the JWTgroupsclaim. The policy authorizer resolves a repo to its owning namespace (role_scope: repo_namespace) or a namespace path (role_scope: namespace_path) and compares the caller's INHERITED role (parent-namespace grants apply to child resources), with the explicitsite:maintainer+bypass. git_repo's resource-level checks were narrowed to the same namespace effective-role model. Picoforge policy role-gates repo reads AND writes (read_tree/read_fileatreporter,put_file/makeatdeveloper,set_publicatmaintainer) over the repo's namespace. Public repos are readable by anyone — including anonymous callers — via an explicitallow_publicrule flag. The site-admin bypass is opt-in per policy rule (site_bypass: true), never implicit for every role-gated endpoint.accounts.ns_createis hardened against privilege escalation (owner is the verified caller; the owner grant happens only when the namespace is freshly created; the reservedsitenamespace and subgroups the caller can't administer are refused).accounts.ns_add_memberandgit_repo.makereject targets that do not exist in the canonical namespaces table, and a repo row records itsnamespace_id. Namespace slugs are validated to the strict[A-Za-z0-9._-]segment grammar so a slug can never smuggle a second<path>:<role>into the comma/colon-delimited groups claim. Root-namespace (top-level group) creation is GitLab-style: by default any authenticated user may create one (and becomes its owner), but the deployment can restrict it to site admins by settingaccounts.allow_user_root_groups: false(mirrors GitLab's "Users can create top-level groups"). The reservedsitenamespace stays creatable ONLY by the trusted internal capability regardless of the flag, and the personal-namespace bootstrap is unaffected. A personal namespace whose name is already a namespace is refused at register so an account is never stranded without ownership; subgroups require maintainer+ on the parent.git_repo.makeanddeletecarry service-local namespace checks. Issue and pipeline operations are namespace-scoped too:issues.open/count_open_in_repoandgit_pipeline.enqueue*resolve repo_id→namespace at the gate;issues.closeresolves issue_id→repo→namespace; andgit_pipeline.job_descriptor/read_logare enforced service-local (the leasing runner OR reporter+ on the repo namespace), since that compound rule can't be expressed in policy alone.git_pipeline.lease/lease_jobare runner-token-only (policy group + a service-local owner check). Every repo-scoped write (issues.open/close,git_pipeline.enqueue/enqueue_job,git_repo.make/put_file/set_public/delete) carries a service-local namespace-role check in addition to the gateway policy gate, so a backend reached off the gateway path still enforces RBAC;issues.openrecords the authenticated caller as the issue author, never a client-supplied id. The personal namespace is created (mandatorily, fail-closed, idempotent-for-owner) BEFORE the account-completion marker at register, so a completed account always owns its<username>namespace./repos/newcreates the repo first and only mirrors/redirects on success, never leaving stale registry metadata.Trust is FAIL-CLOSED, not "no JWT means trusted". Every repo/issue/pipeline/ namespace mutation denies a credential-less caller. The gateway's own bootstrap operations (creating a new user's personal namespace, the first-user
sitenamespace, the/repos/newrepo) present an explicit, gateway-signedsystem:internalcapability JWT (PICOMESH_GROUP_SYSTEM) that backends recognise as the trusted-internal marker — it is never issued to a client and never derived from a user's memberships. The first-usersitebootstrap is fail-closed (registration fails if it can't be created). Per-owner repo listings (count_for_owner/list_for_owner) are scoped to the caller's own namespace or a site admin, so private repo names/counts don't leak across namespaces. Note: the namespace authz checks make a nestednamespace_ofresolve RPC; under the pre-existing intermittent mesh "short RPC response" transport flake a legitimate caller can be transiently denied (fail-closed) — the transport flake itself is tracked separately.git_pipeline.completeis guarded service-local (the leasing runner OR maintainer+ on the repo namespace), andgit_repo.deleteis policy-gated (maintainer on repo_namespace, site_bypass) plus the service-local check.Operator/console surfaces (
*.list/*.list_all,issues.status) are NOT in the gateway policy, so they are default-DENIED on the public gateway. They are reachable only on the loopback operator console/bridge — an intentionally network-isolated, app-agnostic tool — and by trusted intra-mesh calls. They are scoped by that network boundary rather than per-resource RBAC, because gating the generic console with namespace roles would break it; the public edge stays fail-closed.The low-level signing oracle is closed:
token_issuer.mint(used only by the runner-token exchange, and fronted unauthenticated by the loopback operator bridge) REFUSES to mint any privilege-granting claim —system:internal, asite:<ladder-role>admin membership, or any<namespace>:<role>ladder membership — so a caller reaching the bridge cannot forge a signed JWT that bypasses RBAC. Non-privileged runner tokens (site:runner,runner:<id>) stay mintable.The first-user
sitebootstrap is created BEFORE the account-completion marker (accounts_register), keyed oncount == 0(no completed accounts yet), and is FAIL-CLOSED with rollback. A backend failure creatingsiteaborts the registration with nothing committed. Ifsiteis created but the subsequentaccounts_registerfails, the just-createdsitenamespace is rolled back viaaccounts.ns_delete(an internal-only method, gated to the namespace owner / site admin /system:internaland refused at the gateway), so a failed first registration can never strandsiteunder a phantom owner with no completed account. The rollback is VERIFIED, not best-effort: ifns_deletecannot removesite(service unreachable / error), the register fails HARD and the username claim is deliberately NOT released — the name stays bound to that uid (so no third party can take it and the same user retrying re-owns and completes), and the operator is warned.ns_deleteitself refuses to delete a namespace that has child namespaces, using an EXACT-prefix descendant check aggregated across all shards. A concurrent first registrant that loses the race forsite(it already exists, owned by another) did not create it and continues as a regular user. The net invariant:site:owneris always backed by a completed account, and the first deployment registrant becomes that owner.Repo path lengths are bounded to prevent silent truncation: a namespace path is stored verbatim in git_repo's fixed-size
owner_namefield and is the basis of the deterministic repo id, so bothgit_repo.make(path_ok) andaccounts.ns_createreject any path that would not fit (identical limits), rather than truncating and desyncing the id from the stored path. The repo creator uid recorded bygit_repo.makecomes from the VERIFIED auth context, never the caller-suppliedowner_idargument (except for the trustedsystem:internalcapability), so a developer on a group cannot poison another user's creator index by passing that user's uid.Projects-page repo discovery (the webapp
/repos) is ROLE-based, not creator-index-based. For each direct membership the caller holds, the webapp expands the namespace to its full subtree viaaccounts.ns_subtree(a reporter+ role inherits down the whole subtree) and lists each namespace's repos. This lists repos in subgroups reached only by INHERITED role (e.g. a developer onacmeseesacme/platform/svcwith no direct subgroup grant), and — because the creator index is no longer consulted for discovery — it does NOT leak repo paths in namespaces the caller created in but has since been revoked from.ns_subtreematches descendants with an EXACT substring-prefix compare (substr(path,1,N)=prefix), never a SQLLIKEpattern: slugs permit_, which SQLiteLIKEtreats as a single-char wildcard, so aLIKE 'a_b/%'would otherwise disclose sibling namespace names likeaxb/....list_for_owner/count_for_ownerremain (caller-or-site-admin scoped) but are not part of access-based discovery.Acceptance tests for issue #30 live in two places: the integration pytest suite
tests/integration/picoforge/test_rbac.py(18 tests over an isolated mesh — the six issue acceptance cases, plus the bypass-class regressions, the mint-forgery guard, and first-user bootstrap) and the[5e]/[5f]blocks oftools/picoforge/mesh-up.sh(the full end-to-end smoke).The Picoforge webapp is namespace-based, not owner-uid-based. Repos carry a per-namespace-path index ("ns:") so a namespace page lists its repos — group repos included — via
git_repo.list_for_namespace/count_for_namespace(reporter+ on the namespace)./_whoamireturns the caller's namespace memberships (path+role, never a JWT), which drives the repo-creation namespace picker (any namespace the user can push to) and a non-admin/groupsarea where namespace owners/maintainers manage members and subgroups; site admins use the same UI under/admin/namespaces. RBAC mutation forms surface the gateway's error instead of silently redirecting, andaccounts.ns_add_memberrefuses a uid with no registered account (so a grant can't pre-authorize a future username). Nested namespaces are addressable: the webapp repo-path parser resolves<namespace-path>/<repo>[/<verb>]from the right (a trailing known verb delimits the repo from a multi-segment namespace), so/acme/platform/svcis the reposvcinacme/platform. The repo-creation namespace field is free text (with the user's pushable namespaces as suggestions) so an INHERITED subgroup can be entered;/groupslikewise lets a maintainer jump to an inherited subgroup by path.accounts.ns_listandaccounts.ns_membersare fail-closed in-service (site-admin / maintainer-on- namespace), not only at the gateway;ns_membersjoins the username so the management UI works for non-admin maintainers without an admin roster fetch.git_repo.makeidempotently repairs the owner/namespace indexes when a duplicate is detected, so a partial-failure retry still lists the repo. The owner index stores each repo's FULL PATH (<namespace>/<repo>), so a group repo created by a user links to its namespace URL (e.g./acme/bobweb) on/repos, search, and the issue dashboard — not/<username>/<repo>. Repo names and namespace slugs may not be URL route words (issues,runs,edit,new,settings; namespaces also reserverepos/admin/groups/…), so the<namespace>/<repo>/<verb>parse stays unambiguous and a personal namespace can't shadow a top-level route. The repo-scoped issue reads (count_open_in_repo,status) are namespace-role-gated service-local, and the unbounded cross-repo scans (issues/git_pipelinelist/list_all) are site-admin-only service-local (fail-closed) — they are operator/bridge surfaces, default-denied at the public gateway. -
PAT support is not yet real opaque-token auth. Sequential numeric ids are not acceptable bearer credentials.
-
Password verification remains demo-grade and must be replaced before treating this as production security.
- Keep
securityas a low-level library only. Remove policy decisions and any invalid-JWT fallback behavior from it. - Implement the namespace tree and namespace membership authority: user namespaces, group namespaces, optional nested sub-namespaces, repo ownership by namespace, and effective-role resolution through parent namespaces.
- Move the auth pipeline into the CORE invoke boundary (not a frontend), so
yhttp, yrpc/msgpack, bridges, and future frontends share it by carrying
headers and invoking — no per-frontend auth code. Boundary vs internal vs
trusting is each node's own
securityconfig. - Implement configured authenticator modules:
session_cookie,bearer_jwt_token, and genericbearer_opaque_token. - Make opaque authenticators call configured lookup service paths and verify returned JWTs.
- Implement a configured authorizer. The first target should be the
yaapp-compatible
policyauthorizer, extended with resource resolution such asrepo_id -> namespace -> effective role. If Picomesh wants authz as a mesh plugin/service instead, define that contract explicitly. - Move browser login/session composition to the
sessionservice shape:session.start -> token_issuer.login -> store JWT under sid -> Set-Cookie. - Redesign
sessionrows with idle timeout, absolute lifetime, refresh token, and logout deletion. - Replace numeric PATs with random opaque tokens stored by hash and exchanged for JWTs by the PAT service.
- Pass verified JWT/auth context through local and remote calls. Backend resource checks must use verified auth context or fail closed.
- Replace demo password hashing with a real password KDF and per-password salt.
- Add tests for both success and failure: missing credential, malformed credential, expired session/JWT, absent policy entry, anonymous endpoint, namespace role inheritance, and valid cookie/PAT/runner token flows.
The target design provides:
- default deny for methods absent from policy;
- no credential downgrade on malformed tokens;
- clear separation of authn and authz;
- clear separation between the core auth mechanism and the mesh service plugins;
- one core policy decision at the invoke boundary before service invocation;
- short-lived JWT claims with refresh/login updates;
- browser sessions that expose only opaque HttpOnly cookies;
- support for API clients using bearer JWTs or bearer opaque tokens;
- explicit deployment boundary for backend services without their own auth.