Skip to content

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.

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 | deny

Customer 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.

Desktop client sends package/action context:

apps/protected-registry-desktop/src/fractalops/credential-broker-client.mjs

Request 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.py

Current behavior after this implementation slice:

  • requires project_slug for 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 requested action_mode
  • includes project_slug, package_name, package_scope, allowed_actions, and entitlement_snapshot_id in the registry JWT
  • exposes GET /v1/packages/registry/entitlements for desktop UI action availability
  • persists package ACL policy in protected_registry_package_policies
  • exposes package policy management through:
    • GET /v1/packages/registry/policies
    • PUT /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_paths and delivery_group_paths
  • protected build and delivery bundle/grant APIs require package-level build or deliver entitlement 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_token before minting an install-only npm token
  • registry JWTs now include package_version, customer_slug, and allowed_versions when 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.

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_slug
package_name
version
grant_token
-> protected_registry_customer_grants
-> install-only npm token
-> Nexus tarball version check

deliver still means an internal FractalOps actor can create customer delivery bundles/grants. It does not mean the customer can install every version.

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"
}

Minimum viable package policy:

tenant_id
project_slug
package_name
package_scope
status: active | disabled | archived
install_groups[]
publish_groups[]
owner_groups[]
created_at
updated_at

Effective actions:

owner_groups -> install, publish
publish_groups -> install, publish
install_groups -> install
build_groups -> build
delivery_groups -> deliver
package role path owner/maintainer/publisher -> install, publish
package role path owner/maintainer/builder -> build
package role path owner/maintainer/delivery/deliverer -> deliver
package role path viewer/member/consumer -> install
no package policy -> deny
disabled package -> deny

Customer grant policy:

customer_slug
package_name
allowed_versions[]: exact or simple ranges like ">=1.4.0 <1.5.0"
allowed_dist_tags[]
grant_token_hash
expires_at
max_downloads
download_count
status

Customer 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"]
}

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/npm

This route does not require user OIDC. It requires a valid customer grant token and exact requested version.

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/entitlements

Implemented behavior:

project input changes
-> fetch visible package entitlement list
-> fill package datalist
project/package input changes
-> fetch effective actions
-> disable install/publish options not in allowed_actions
-> show deny reason in Local Audit

Suggested 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 build
delivery form enabled only when allowed_actions contains deliver
POST /v1/packages/protection/builds requires build
POST /v1/packages/delivery/bundles requires deliver
POST /v1/packages/delivery/grants requires deliver on the source bundle package

Desktop 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/policies

Nexus plugin must enforce token claims at request time:

requested package == token.package_name or within token.package_scope
requested tarball version == token.package_version or within token.allowed_versions
requested action in token.allowed_actions
token not expired
token audience == nexus

FractalOps must deny before token mint. Nexus must deny again at package operation time.

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.
  • 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
  • publish allowed only when package policy grants publish or owner/maintainer role
    • covered by test_package_entitlement_allows_publish_for_package_owner_group
  • project role without explicit package policy denies install/publish
    • covered by test_package_entitlement_denies_project_role_without_package_policy
  • missing package denies token mint
    • covered by test_package_entitlement_denies_missing_package
  • disabled package denies token mint
    • covered by test_package_entitlement_denies_disabled_package
  • allowed_actions in JWT reflects effective package entitlement, not just requested action
    • covered by test_exchange_registry_token_uses_effective_package_entitlement_snapshot
  • UI disables unavailable actions before command queue
    • implemented in apps/protected-registry-desktop/src/ui/shell.js
  • 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
  • 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
  • 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
  • browser e2e proves action options disable against a live local API
    • covered by ui-entitlement-live-api.test.mjs
  • backend still returns a clear 403 if UI is stale
    • covered by test_exchange_registry_token_rejects_action_outside_effective_package_entitlement
  • 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
  • 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
  • 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