Package access
By default, everyone with package access in your organisation reaches every package in it: a member of a team with package write can publish to, and delete, any package of the organisation, whichever repository it belongs to. Per-package access narrows that down, the way GitHub Packages and GitLab do it.
Nothing changes until an organisation owner turns it on, for one package or for the whole organisation.
What per-package access does
For a package on per-package access:
| Who | Gets |
|---|---|
| Organisation owners | Full access (read, publish, delete). |
| A person or a registry robot | The highest of: their permission on the package’s linked repository (read, write or admin), and the permission of every team grant on the package for a team they are in. |
| A Forgejo Actions job token | Publish if the package is linked to the job’s own repository; otherwise what an Actions grant for the job’s repository gives (read or write). A fork pull request run only ever reads. |
| Anyone else | Nothing beyond what the organisation’s visibility gives (below). |
- Read downloads the package. Write publishes, adds a file and moves a tag. Admin also deletes versions and links or unlinks the repository. A registry robot never deletes, whatever its grants.
- Visibility is unchanged. On a public organisation anyone can still read every package, and on a limited one any signed-in user can. Per-package access then limits who may publish and delete. On a private organisation it limits reading too.
- It can only narrow. Per-package access never gives anyone more than the organisation’s own package settings do. To let a team publish one package, its members need package write in the organisation (the Members team has it), and per-package access then keeps them to that package. The one exception is the Actions grant with write, which lets another repository’s jobs publish a package linked elsewhere.
- Linked repository. A package is linked to a repository when it is published by an Actions
job of that repository, from its metadata (npm
repository, the containerorg.opencontainers.image.sourcelabel), or in the package’s settings. Turn Inherit from the linked repository off to rely on grants alone.
Per-package access is available for every package type the registry serves: Alpine, ALT, Arch, Cargo, Chef, Composer, Conan, Conda, container, CRAN, Debian, generic, Go, Helm, Maven, npm, NuGet, Pub, PyPI, RPM, RubyGems, Swift and Vagrant.
Turn it on
In the console: Org admin → Security → Access → Package access.
- Organisation default: Organisation-wide (the default) or Per package, for every package of a supported type that has no setting of its own.
- Configure a package: per package, Per package, Organisation-wide or Follow the organisation, and whether it inherits from its linked repository. The package does not need to exist yet: a setting made before the first publish applies to it.
- Add a grant on a configured package: a team (read, write or admin) or a repository’s Actions jobs (read or write).
Or with the API, with an API token with the policy:write scope (policy:read
to read). A / in a package name is sent as %2F:
API=https://frem.sh/_app/api/v1/orgs/acme
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"mode":"granular","inherit_repo":true}' "$API/packages/container/team%2Fapp/access"
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"permission":"write"}' "$API/packages/container/team%2Fapp/access/teams/release-managers"
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"permission":"write"}' "$API/packages/container/team%2Fapp/access/actions/release-pipeline"Protected names
A protection rule names packages by type (or every supported type) and a name pattern, and lists who may publish to them and who may delete them: organisation owners, registry robots, members of named teams, and the Actions jobs of named repositories. Anyone else is refused, whatever the package’s access setting. When several rules match a package, all of them must allow.
In the pattern * matches any characters, a / included, and ? one character; case does not
matter. For example, release/* with May publish: registry robots, Actions jobs of
release-pipeline keeps release images to CI.
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"type":"container","name_pattern":"release/*","push":{"owners":false,"robots":true,"actions":["release-pipeline"]},"delete":{"owners":true}}' \
"$API/packages/protection-rules"Package lists and indexes
In an organisation that uses per-package access, lists and indexes leave out the packages the caller cannot read, by name and with every version:
- the package list of the API,
/api/v1/packages/<org>; - npm search, the Helm
index.yaml, the NuGet search (query), Composersearch.jsonandlist.json, Cargo search, the Chefsearchanduniverse, Condarepodata.json, RubyGemsversions, CRANPACKAGES, Conan search and the Swiftidentifierslookup.
The registry checks the list on the way out, which typically adds less than a tenth of a second to these
requests. If it cannot check a list, it answers 503 with Retry-After rather than show it
unfiltered. Organisations that do not use per-package access get the registry’s lists unchanged.
What a refusal looks like
A refused request answers 403 with the reason (DENIED in the container registry’s error
format), before anything is written. Every refusal is in the organisation’s
audit log as package_access.denied, and a refused
publish or delete also as package.upload.refused or package.delete.refused with reason
package_access:<reason>.
As code
The configuration-as-code module manages all of it:
module "org" {
source = "./modules/fremforge-org"
org = "acme"
package_access_default = "legacy"
packages = {
"container/team/app" = {
mode = "granular"
teams = { release-managers = "admin" }
actions = { release-pipeline = "write" }
}
}
package_protection_rules = {
releases = { type = "container", name_pattern = "release/*", push = { owners = false, robots = true } }
}
}Limits
- A change to a setting, a grant, a rule or a team’s members applies within a minute: the registry caches its decisions briefly.
- These lists are not filtered: the package list in the web UI, signed repository indexes (the
Debian
Release/Packages, RPMrepodata, AlpineAPKINDEX, Arch database and ALT base files, whose signature a rewrite would break), compressed variants (PACKAGES.gz,repodata.json.bz2, the RubyGemsspecs.4.8.gzfamily), the NuGet v2 XML feeds and the container tag list. They show package names and versions to everyone who can read the organisation’s packages, but not the packages’ contents. - An upload that names its package only inside the uploaded file is checked against that name.
Where the registry cannot read the name (a Conda
.tar.bz2, an xz-compressed Debian control archive or Arch package), the upload is refused in an organisation that uses per-package access for that type. Use.conda, or gzip or zstd compression. - A RubyGems yank must name the gem in the query string, for example
curl -X DELETE ".../rubygems/api/v1/gems/yank?gem_name=mygem&version=1.0.0". A yank that names it only in a form body is refused in an organisation that uses per-package access for RubyGems.