Protected Registry Package Entitlement Gap
Protected Registry Package Entitlement Gap
Section titled “Protected Registry Package Entitlement Gap”This note captures the current gap found while reviewing the Protected Registry Desktop App and FractalOps registry token flow.
Problem
Section titled “Problem”Install, publish, build, and delivery are not just package-manager or artifact commands.
Each request needs package-level entitlement:
subject -> project -> package -> action: install | publish | build | deliver -> allow | denyCustomer delivery is different. Customers usually cannot use FractalOps OIDC. They need a customer grant that allows install for one package version or a small explicit version range.
The same user can be allowed to install a package but denied publish, build, or delivery. Another user can be denied all actions. A package can also be unknown, disabled, private, or unassigned.
Current Implementation
Section titled “Current Implementation”Desktop client sends package/action context:
apps/protected-registry-desktop/src/fractalops/credential-broker-client.mjsRequest body:
{ "project_slug": "<project>", "package_name": "<package>", "action_mode": "install|publish", "ttl_seconds": 0}Backend token service:
backend/src/fractalops/contexts/access/application/integration/registry_login.pyCurrent behavior after this implementation slice:
- requires
project_slugfor protected registry token requests - resolves package-level entitlement before token mint
- denies missing packages with
protected_registry_package_not_found - denies disabled/archived/inactive packages with
protected_registry_package_disabled - denies action mismatch with
protected_registry_package_action_denied - uses effective
allowed_actions, not only requestedaction_mode - includes
project_slug,package_name,package_scope,allowed_actions, andentitlement_snapshot_idin the registry JWT - exposes
GET /v1/packages/registry/entitlementsfor desktop UI action availability - persists package ACL policy in
protected_registry_package_policies - exposes package policy management through:
GET /v1/packages/registry/policiesPUT /v1/packages/registry/policies
- desktop CLI exposes:
protected-registry policies --project <slug> [--package <name>]protected-registry set-policy --project <slug> --package <name>
- desktop UI Packages tab can load and save package policy group paths through Tauri commands backed by the same CLI/API path
- package policy now models
build_group_pathsanddelivery_group_paths - protected build and delivery bundle/grant APIs require package-level
buildordeliverentitlement before recording audit/evidence - customer package install uses
protected_registry_customer_grants, not OIDC user groups - customer grants authorize
customer_slug + package_name + version + grant_tokenbefore minting an install-only npm token - registry JWTs now include
package_version,customer_slug, andallowed_versionswhen minted from a customer grant - Nexus blocks tarball downloads when the requested tarball version does not match the customer token version entitlement
- browser e2e serves the desktop UI and live local entitlement/policy API endpoints, then verifies disabled install/publish/build/delivery controls
Relevant resolver:
resolve_protected_registry_package_entitlement( tenant_id=tenant_id, project_slug=project_slug, package_name=package_name, principal=principal, packages=packages,)The resolver now uses package-level group policy from the Project Factory package
read model merged with persistent ProtectedRegistryPackagePolicy rows. A
persistent policy row overrides the package read model for status and
install/publish/owner group paths.
Current Status
Section titled “Current Status”The current branch now enforces package existence/status/action before token mint, supports persistent package policies, and verifies the desktop UI against a live local entitlement API in a real browser.
Current policy sources are the Project Factory package read model and the persistent package policy table. This is enough to deny missing packages, disabled packages, stale action requests, and missing explicit package ACLs.
Customer installs are now modeled separately from internal OIDC users:
customer_slugpackage_nameversiongrant_token -> protected_registry_customer_grants -> install-only npm token -> Nexus tarball version checkdeliver still means an internal FractalOps actor can create customer delivery
bundles/grants. It does not mean the customer can install every version.
Required Policy Shape
Section titled “Required Policy Shape”Required decision input:
{ "tenant_id": "default", "project_slug": "cosmetics-review-site", "package_name": "@yamon/ui-kit", "subject_key": "user@yamon.io", "principal_type": "human", "group_paths": [ "/org/projects/cosmetics-review-site/packages/@yamon/ui-kit/roles/viewer" ], "action_mode": "install"}Required decision output:
{ "allow": true, "package_exists": true, "allowed_actions": ["install"], "deny_reason": "", "entitlement_snapshot_id": "sha256:<digest>"}Denied examples:
{ "allow": false, "package_exists": false, "allowed_actions": [], "deny_reason": "protected_registry_package_not_found"}{ "allow": false, "package_exists": true, "allowed_actions": ["install"], "deny_reason": "protected_registry_package_action_denied"}Recommended Entitlement Model
Section titled “Recommended Entitlement Model”Minimum viable package policy:
tenant_idproject_slugpackage_namepackage_scopestatus: active | disabled | archivedinstall_groups[]publish_groups[]owner_groups[]created_atupdated_atEffective actions:
owner_groups -> install, publishpublish_groups -> install, publishinstall_groups -> installbuild_groups -> builddelivery_groups -> deliverpackage role path owner/maintainer/publisher -> install, publishpackage role path owner/maintainer/builder -> buildpackage role path owner/maintainer/delivery/deliverer -> deliverpackage role path viewer/member/consumer -> installno package policy -> denydisabled package -> denyCustomer grant policy:
customer_slugpackage_nameallowed_versions[]: exact or simple ranges like ">=1.4.0 <1.5.0"allowed_dist_tags[]grant_token_hashexpires_atmax_downloadsdownload_countstatusCustomer token request:
{ "customer_slug": "acme", "package_name": "@yamon/protected-sdk", "version": "1.4.2", "grant_token": "<customer-secret>"}Customer token claims:
{ "sub": "customer:acme", "package_name": "@yamon/protected-sdk", "package_version": "1.4.2", "customer_slug": "acme", "allowed_actions": ["install"], "allowed_versions": ["1.4.2"]}Backend Implementation
Section titled “Backend Implementation”Package entitlement resolver:
resolve_protected_registry_package_entitlement( tenant_id=tenant_id, project_slug=project_slug, package_name=package_name, subject_key=subject_key, principal_type=principal_type, group_paths=group_paths, action_mode=action_mode,)Used before JWT minting in:
PortalCredentialBrokerService.mint()Then enforced inside:
RegistryTokenService.exchange_token()Before JWT minting, these denials are now expected:
if package missing: raise PermissionError("protected_registry_package_not_found")
if package disabled: raise PermissionError("protected_registry_package_disabled")
if requested action denied: raise PermissionError("protected_registry_package_action_denied")Token claims should use the resolver result:
{ "package_name": "@yamon/ui-kit", "allowed_actions": ["install"], "entitlement_snapshot_id": "sha256:<package-policy-digest>"}Customer install token minting uses:
POST /v1/packages/registry/customer-tokens/npmThis route does not require user OIDC. It requires a valid customer grant token and exact requested version.
UI Implementation
Section titled “UI Implementation”Desktop app now has a native entitlement lookup path:
UI -> protectedRegistryEntitlements(...) -> Tauri protected_registry_entitlements -> protected-registry entitlements --project <slug> --package <name> -> GET /v1/packages/registry/entitlementsImplemented behavior:
project input changes -> fetch visible package entitlement list -> fill package datalistproject/package input changes -> fetch effective actions -> disable install/publish options not in allowed_actions -> show deny reason in Local AuditSuggested endpoint:
GET /v1/packages/registry/entitlements?project_slug=<slug>Response:
{ "items": [ { "package_name": "@yamon/ui-kit", "status": "active", "allowed_actions": ["install"], "deny_reasons": { "publish": "protected_registry_package_action_denied" } } ]}Implemented UI behavior:
- package datalist hides packages with no visible action
- explicit package lookups still show disabled action reasons in Local Audit
Implemented build/delivery behavior:
build form enabled only when allowed_actions contains builddelivery form enabled only when allowed_actions contains deliverPOST /v1/packages/protection/builds requires buildPOST /v1/packages/delivery/bundles requires deliverPOST /v1/packages/delivery/grants requires deliver on the source bundle packageDesktop CLI package policy management now exists:
protected-registry policies --project <slug> [--package <name>]protected-registry set-policy \ --project <slug> \ --package <name> \ --install-group <path> \ --publish-group <path> \ --build-group <path> \ --delivery-group <path> \ --owner-group <path>Customer grant CLI:
protected-registry set-customer-grant \ --project <slug> \ --customer <slug> \ --package <name> \ --version <version-or-range> \ --grant-token <token>Customer install CLI:
protected-registry customer-install <package> \ --customer <slug> \ --version <version> \ --grant-token <token>Desktop UI package policy management now exists in the Packages tab:
Packages -> policy form -> Tauri upsert_protected_registry_policy -> protected-registry set-policy -> PUT /v1/packages/registry/policiesRequired Nexus Enforcement
Section titled “Required Nexus Enforcement”Nexus plugin must enforce token claims at request time:
requested package == token.package_name or within token.package_scoperequested tarball version == token.package_version or within token.allowed_versionsrequested action in token.allowed_actionstoken not expiredtoken audience == nexusFractalOps must deny before token mint. Nexus must deny again at package operation time.
Previous Risk
Section titled “Previous Risk”Previous project-level check was too broad.
Example risk:
User has project_contributor on project A.User requests publish token for @yamon/sensitive-package in project A.If no package-level policy exists, current project-level check can allow.Expected behavior:
No explicit package publish entitlement -> deny.Current behavior:
No package match or no effective publish action -> deny before token mint.Acceptance Criteria
Section titled “Acceptance Criteria”- install allowed only when package policy grants install or a broader package role
- covered by
test_package_entitlement_allows_install_only_for_viewer - covered by
test_package_policy_service_persists_and_drives_effective_actions
- covered by
- publish allowed only when package policy grants publish or owner/maintainer role
- covered by
test_package_entitlement_allows_publish_for_package_owner_group
- covered by
- project role without explicit package policy denies install/publish
- covered by
test_package_entitlement_denies_project_role_without_package_policy
- covered by
- missing package denies token mint
- covered by
test_package_entitlement_denies_missing_package
- covered by
- disabled package denies token mint
- covered by
test_package_entitlement_denies_disabled_package
- covered by
allowed_actionsin JWT reflects effective package entitlement, not just requested action- covered by
test_exchange_registry_token_uses_effective_package_entitlement_snapshot
- covered by
- UI disables unavailable actions before command queue
- implemented in
apps/protected-registry-desktop/src/ui/shell.js
- implemented in
- package policy API and desktop CLI can list/upsert persistent package policy
- covered by
test_protected_registry_policy_routes_are_registered - covered by
test_package_policy_service_persists_and_drives_effective_actions - covered by
credential-broker-client.test.mjs - covered by
cli-surface.test.mjs
- covered by
- desktop UI can invoke package policy list/upsert without token exposure
- covered by
ui-bridge.test.mjs - covered by
tauri-host.test.mjs - covered by
loopback-ui.test.mjs
- covered by
- build and delivery actions are authorized by package policy
- covered by
test_package_policy_service_allows_build_and_delivery_separately - covered by
test_route_package_action_helper_enforces_build_delivery_entitlements - UI disable behavior is covered by
ui-bridge.test.mjs
- covered by
- browser e2e proves action options disable against a live local API
- covered by
ui-entitlement-live-api.test.mjs
- covered by
- backend still returns a clear 403 if UI is stale
- covered by
test_exchange_registry_token_rejects_action_outside_effective_package_entitlement
- covered by
- Nexus rejects mismatched package/action even with otherwise valid token
- covered by
test_nexus_plugin_blocks_install_when_token_package_claim_mismatches - covered by
test_nexus_plugin_blocks_publish_when_token_action_claim_is_install - covered by
test_nexus_plugin_blocks_customer_tarball_version_outside_claim
- covered by
- customer grant allows only specific package versions without OIDC login
- covered by
test_customer_grant_authorizes_only_allowed_package_version - covered by
test_exchange_registry_token_allows_customer_grant_without_project_slug - covered by
test_protected_registry_customer_grant_routes_are_registered - covered by
credential-broker-client.test.mjs
- covered by
- tests cover install-only, publish-only, both, none, missing package, disabled
package, and stale UI request
- install-only, publish, build, deliver, none, missing, disabled, stale action, and live browser/API UI disable behavior are covered