opteryx-access
Permission checks and grant/revoke for the Opteryx platform, as an installable
library rather than a service. Every consumer imports opteryx_access and
calls it in-process against policies it already has in hand -- a JWT's
policies claim, or a PolicyStore backed by whatever it uses for storage
(Firestore, today). There is no opteryx-access HTTP surface, and this
package makes no network calls of its own beyond what a storage adapter does.
Scope: data access only
This package models exactly one thing: who can do what to data --
owner/writer/reader on workspaces, collections, and datasets. It has no
notion of billing-account roles (admin/member, minted separately on a
billing account and unrelated to data roles) and enforces no billing checks.
The one place these two systems touch is workspace genesis: creating a
workspace is a billing-gated action (the creator must already be a billing
admin -- checked by whatever service owns billing, not this one), and its
result is a data-permission fact -- the creator becomes the new workspace's
owner. That handoff is the entire intersection. opteryx_access.grants.bootstrap_workspace
implements the data-permission half of it (an explicit list of
(principal, role) pairs, owner among them) and knows nothing about
billing; the billing gate is the calling service's job, enforced before
bootstrap_workspace is ever reached.
Why this exists
Three independent, subtly incompatible implementations of "does this role satisfy this requirement" already exist in the fleet:
- policy.opteryx / control.opteryx (
app/routes/v1/access.py,app/models/policy.py-- byte-for-byte duplicated between the two repos): a rank-basedROLEStuple used to decide who may create/update/revoke a policy, and whether a new grant is redundant against one the principal already holds. - opteryx-core (
opteryx/managers/permissions/__init__.py): a set-basedACTION_MAPdeciding which roles mayREAD/DELETE/DROP/etc. a resource once a query actually runs. - odata.opteryx (
app/auth/permissions.py): a binaryrole_allows_readused for listing/visibility, plus its own pattern matcher --fnmatchcasewhere the others use plainfnmatch, a difference that is latent today (see "Behavior changes") but means the three do not state the same rule even where they currently agree.
On top of that, policy.opteryx/control.opteryx/odata.opteryx/
register.opteryx each carry their own copy of "parse the policies claim
out of a decoded JWT." Comments in several of these point at an
authorize.opteryx service (app.routes.v1.evaluate) as the semantics every
copy is meant to mirror -- but no such repo exists anywhere in this
workspace. Whether it's a real service in another org/remote or was never
built, every consumer today reimplements its own understanding of "role +
pattern -> allowed" independently, which is exactly the drift this package
is meant to stop.
What lives here
| Module | Ported from | Purpose |
|---|---|---|
roles.py |
policy.opteryx/app/models/policy.py |
The canonical ROLES = ("owner", "writer", "reader") and role_outranks_or_equals, the privilege ordering used only for redundancy detection. |
actions.py |
opteryx-core/opteryx/managers/permissions/__init__.py |
ACTION_ROLES: which roles may perform READ/WRITE/DELETE/CREATE/DROP/ALTER/REFRESH/MANIFEST, plus GRANT/REVOKE (new -- makes policy-administration authority explicit in the same table instead of an implicit rule elsewhere). |
patterns.py |
policy.opteryx/app/models/policy.py + app/routes/v1/access.py |
What a pattern and a principal may look like, and how a pattern matches: validate_pattern, validate_principal, resource_matches (see "Patterns and principals" below). |
models.py |
authenticate.opteryx/app/policies.py |
Grant (role+pattern, the JWT-carried shape) and Policy (principal+role+pattern+metadata, the stored shape), plus parse_policy_claim for the [role, pattern] pairs a token carries. |
checks.py |
opteryx-core's can_perform_action/can_perform_workspace_action + policy.opteryx's _check_pattern_access/_check_workspace_access |
The evaluation layer: data-plane checks over Grants, administrative-plane checks over Policy documents. |
grants.py |
policy.opteryx/app/routes/v1/access.py's create_policy/update_policy/delete_policy/create_genesis_policies |
The write half: grant()/update_grant()/revoke()/bootstrap_workspace(), enforcing every rule those routes did (self-grant prevention, pattern authority, conflict detection, principal and pattern validation) before calling a store. Also the two reads that need a store: grants_for_principal() and owned_by(). |
store.py |
(new) | The PolicyStore protocol: the storage contract, and nothing else. No rules live here -- see "Layering" below. |
audit.py |
policy.opteryx/app/routes/v1/access.py's _audit_policy_change |
One structured record per policy change, on the same field contract the existing log transforms already parse -- see "Audit records" below. |
adapters/firestore.py |
(new) | FirestorePolicyStore, matching the {workspace}/$policies/access layout policy.opteryx/control.opteryx already write to -- a drop-in for their inline Firestore calls. |
exceptions.py |
(new) | Plain exceptions (SelfAccessError, AccessDeniedError, PolicyConflictError, ...) instead of HTTPException -- each caller translates to its own transport. |
Layering
Rules and storage are separate, and the dependency only points one way:
roles / actions / patterns / models the vocabulary (no I/O, no deps)
|
checks.py read-side rules (no store at all)
|
grants.py write-side rules -- the only module
| that mutates a store
store.py the storage contract (a Protocol)
|
adapters/firestore.py one backend
Two consequences worth relying on:
checks.pynever touches a store. It answers from grants handed to it, so opteryx-core can evaluate a query's permissions from a JWT with no storage, no credentials, and no network.- A store enforces nothing. It translates to its backend and no more. A
store that filtered out policies it thought invalid, or refused a write it
thought unauthorized, would be enforcing policy somewhere none of the tests
for those rules can see it. Every rule lives in
grants.py, tested against an in-memory fake.
Patterns and principals
A policy says who gets what role over which resources.
Which resources is a pattern: workspace[.collection[.dataset]], where each
dot-separated segment is either a literal name or * standing for any one
segment. Rules, all enforced by validate_pattern:
- Names are lowercase,
a-z0-9_, starting with a letter. Patterns and resource names are normalized before they are compared, so matching is case-insensitive and decided identically on every platform. - The workspace segment must be literal. A policy always says which
workspace it applies to --
analytics.*is fine, a bare*is not. - A segment is either
*or a whole literal name -- no partial globs likepub*. This is what makes the reserved-workspace check a plain membership test rather than a match against every reserved name, with no evasion to reason about. public,personal, andinformation_schemaremain non-grantable.
Who is a named individual, enforced by validate_principal. There is no
wildcard principal and no group principal: a grant everyone holds is not
something any listing surfaces as unusual, and groups will be their own
concept rather than a pattern smuggled into this field.
Identities are casefolded too, so XB500 and xb500 are one principal rather
than two people each holding half the access. Normalizing on write alone would
not be enough -- a lookup for xb500 would silently miss a stored XB500 --
so every read that filters by principal normalizes the same way.
Audit records
Every successful change to a policy emits one structured record, after the
write lands. grant, update_grant, revoke, and bootstrap_workspace all
do this themselves -- not the caller -- because a trail assembled by whoever
remembers to assemble it is one refactor away from having a hole in it, and a
hole here is invisible until someone needs it.
Records go to the standard-library logger opteryx_access.audit at INFO.
Being a library, this configures nothing -- no handlers, no levels. A
service that does not configure logging for that logger will not see these at
all, which for an audit trail is worth confirming rather than assuming.
Each record is emitted twice over, so it survives whatever is in front of it:
as compact single-line JSON in the message (Cloud Run promotes a JSON stdout
line into jsonPayload on its own), and via extra={"json_fields": ...}
(what google-cloud-logging's handlers read directly). A service that would
rather route these through its own audit channel can call set_audit_sink
and be handed the payload dict.
{"event": "policy.updated", "actor": "alice", "workspace": "analytics",
"policy_id": "9f2c...", "principal": "xb500", "role": "writer",
"pattern": "analytics.sales.q1", "previous_role": "reader",
"previous_pattern": "analytics.sales.*",
"timestamp": "2026-08-12T08:06:37.516307+00:00"}
event is policy.created, policy.updated, or policy.deleted. The field
names deliberately match what policy.opteryx/control.opteryx already emit, so
the transforms building opteryx.ops.policy_changes keep working unchanged
when a service moves onto this package. Absent fields are omitted rather than
null: no previous_* on a create, no role/pattern on a delete. Genesis
grants additionally carry bootstrap: true, since they clear none of the
usual authority checks and are worth telling apart from an ordinary grant.
Only changes that actually happened are recorded. A refused grant writes nothing -- see "Not recorded" at the end of this file for what that leaves out.
Which check to call
- "May this identity administer grants on this pattern?" ->
checks.can_administer_pattern, over storedPolicydocuments. Requiresowner, and requires that ownership to cover the pattern in question. - "May this role perform this action on this resource?" ->
checks.can_perform_action, overGrants. Answered fromACTION_ROLES.
They take different inputs because they need different things: a Grant is
role plus pattern, all a data action needs; a Policy also names the
principal it was issued to, which is what an administrative check reasons
about. Call the one that fits the question -- neither answer substitutes for
the other.
Usage
Every check function takes grants/policies already in hand -- it doesn't fetch them itself. Where those come from depends on what you're holding:
- A JWT:
opteryx_access.models.parse_policy_claim(claims)-- the token was already scoped to its own holder when it was minted, so nothing more to filter. - A
PolicyStore(live policy state, not a token):opteryx_access.grants.grants_for_principal(store, workspace=..., identity=...)-- fetches that identity's issued grants and converts them to the sameGrantshape.
One question is asked across workspaces rather than within one:
opteryx_access.grants.owned_by(store, identity=...) returns every policy, in
any workspace, that makes an identity an owner. That is what offboarding
needs -- a workspace whose last owner is removed cannot be administered by
anyone, so those grants have to be reassigned before the identity goes. Each
returned Policy carries its workspace.
There is deliberately no "everywhere this identity can read" equivalent: it would mostly return reader rows nobody acts on, while being the expensive query shape and widening what a backend has to index. If a use for it turns up, it should arrive as its own named operation with that use written down.
from opteryx_access import Grant, can_perform_action
grants = [Grant(role="writer", pattern="analytics.sales.*")]
can_perform_action(grants, "analytics.sales.q1", "DELETE") # True
can_perform_action(grants, "analytics.sales.q1", "DROP") # False -- writer, not owner
from opteryx_access.grants import grants_for_principal
from opteryx_access.adapters.firestore import FirestorePolicyStore
store = FirestorePolicyStore(db)
grants = grants_for_principal(store, workspace="analytics", identity="bob")
can_perform_action(grants, "analytics.sales.q1", "DELETE")
from opteryx_access import grant, revoke, AccessDeniedError
from opteryx_access.adapters.firestore import FirestorePolicyStore
store = FirestorePolicyStore(db) # db: google.cloud.firestore.Client
try:
policy_id = grant(
store, actor="alice", workspace="analytics",
principal="bob", role="writer", pattern="analytics.sales.*",
)
except AccessDeniedError:
... # translate to a 403, same as the route used to do inline
Behavior changes from the ported originals
Ported faithfully except for four deliberate deviations. Each is stricter than what the originals accept, so each can reject a policy that exists today -- audit stored policies before cutting a service over, rather than assuming any of this is a no-op:
ROLESis("owner", "writer", "reader")-- three roles, not four.policy.opteryx/control.opteryx's currentROLES/VALID_WORKSPACE_ROLESinclude a fourth,admin. This package intentionally drops it:adminis a billing-account concept, not a data-permission one (see "Scope" above), and never belonged in the data-role vocabulary.- Matching is case-insensitive. This is the one deviation that grants
more than the originals -- read the note below before cutting over. The
originals use plain
fnmatch, which delegates toos.path.normcase: that is the identity function on every platform this fleet runs (Linux in production, macOS in development), so the originals are effectively case-sensitive andanalytics.*does not matchAnalytics.salesthere. They would only fold case on Windows. Here both sides are lowercased before comparison, soanalytics.*does matchAnalytics.sales. The gain is that the rule is stated rather than inherited from the host OS; the cost is that a stored pattern can now cover resources it did not cover before. - No wildcard principal. The originals accept
principal: "*"as "any authenticated user". Policies here name one individual. - Patterns must be usable and workspace-scoped. The originals accept any
string, including a bare
*, partial globs (pub*), and malformed input (a....*.bob11). See "Patterns and principals" above for the rules.
Suggested migration (not yet done)
This repo is the library only -- nothing outside it has been changed yet.
Before step 3, audit the existing policy documents under
*/$policies/access. Validation runs when a policy is written, not when it
is read, so nothing raises on already-stored data -- the behavior just
changes silently. Three of the deviations fail closed (the grant stops
conferring anything):
role: "admin"-- no longer a role, so the grant becomes inert.principal: "*"-- no longer means "anyone", so the grant reaches nobody.- patterns that are bare
*, partially globbed, or malformed -- they can no longer be written, and are unlikely to match what they used to. - principals stored with any uppercase (
XB500) -- these need rewriting to lowercase, and are the one item here that is not merely inert. Lookups filter server-side on the exact stored string, so a query forxb500will not find a storedXB500: the grant is invisible togrants_for_principalandowned_byeven though the document still exists. In-memory comparisons (can_administer_pattern,has_workspace_access) normalize both sides and so still resolve it, which means a mixed-case principal can behave differently depending on which path reached it. Rewrite them.
The fourth fails open, and needs looking at first:
- case-insensitive matching widens every stored pattern. A pattern only ever matched resources of identical case before; now it also matches those differing in case. If any workspace, collection, or dataset name in the catalog contains an uppercase character, a policy that did not reach it before now does. Enumerate mixed-case resource names before cutover -- if there are none, this deviation is inert and the migration is closed-only.
Decide what happens to each (revoke, or rewrite as an explicit grant); this
package does not migrate them for you. tests/test_security.py pins the
inert-on-read behavior for the closed-failing three if you want the exact
semantics.
Suggested order, each independently shippable:
- opteryx-core: replace
opteryx/managers/permissions/__init__.py'sACTION_MAP/can_perform_action/can_perform_workspace_action/implicit_policieswith thin wrappers aroundopteryx_access.actions/opteryx_access.checks(convertingExecutionContext.access_policiesdicts toGrants at the boundary). Zero new dependencies -- this package has none by default. - odata.opteryx: replace
app/auth/permissions.py'srole_allows_read/read_grant_for_relation/pattern matching withopteryx_access.checks.can_perform_action(action="READ"). Leavesentitlements_from_claims/billing_account_from_claimsalone -- those are a different concern (entitlements/billing), not permissions. - policy.opteryx and control.opteryx: thin
app/routes/v1/access.pydown to request parsing, callingopteryx_access.grants.grant/update_grant/revoke/bootstrap_workspaceviaFirestorePolicyStore, and mapping the typed exceptions toHTTPException. The resource-existence check (_resource_exists), user-status lookup (_lookup_user_record), age gate, and audit logging all stay where they are -- seegrants.py's module docstring for why those are out of scope here. Givencontrol.opteryxis the in-progress merge target forpolicy.opteryx(see itsdocs/design/consolidation.md), do this once, oncontrol.opteryx, and let thepolicy.opteryxcutover carry it along rather than porting both routes separately. - register.opteryx and any other claims-parsing copy: swap to
opteryx_access.models.parse_policy_claimfor thepoliciesclaim specifically. Token verification (signature, issuer, JWKS) stays service-specific -- this package has no opinion on how a JWT gets from bytes to a trusted claims dict, only on what to do with thepoliciesclaim once you have one.
Installing
pip install opteryx_access
pip install "opteryx_access[firestore]" # for adapters.firestore
No hard dependencies. google-cloud-firestore is an optional extra;
adapters/firestore.py never imports it at all (it's duck-typed against
whatever db object it's handed), so importing opteryx_access itself never
requires it -- consistent with opteryx-core's zero-dependency convention.
CI/CD
.github/workflows/tests.yaml-- pytest (3.13, 3.14) + ruff lint/format, on every push tomainand every PR..github/workflows/release.yaml-- on a pushed tag matchingversion-*: runs the full test workflow, checks the tag matchespyproject.toml'sversion(exactly, or with a.-delimited suffix), then builds and publishes to PyPI via trusted publishing (OIDC -- no stored API token). Theopteryx_accessPyPI project needs this repo + therelease.yamlworkflow registered as a trusted publisher before the first tag push, or the publish step will fail with no valid credentials.
To cut a release: bump version in pyproject.toml, merge to main, then
git tag version-X.Y.Z && git push origin version-X.Y.Z.
Not recorded
Refused attempts -- a grant denied for insufficient authority, a self-grant, a rejected pattern -- raise and emit nothing. The records above are a log of changes, and a refusal changes nothing.
That is a deliberate line, not an oversight, but it does mean this package
gives a monitoring system no visibility into someone repeatedly trying to
escalate. If that is wanted, it should be its own event kind (a
policy.denied at WARNING, carrying the actor, what was attempted, and which
rule refused it) rather than being folded into the change records, which
downstream treats as "this is now true".
License
Apache 2.0. See LICENSE for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file opteryx_access-0.1.0.tar.gz.
File metadata
- Download URL: opteryx_access-0.1.0.tar.gz
- Upload date:
- Size: 56.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50f81bcc4bf7e852363c3a83d0991d5f496c173605d5ca932b491f3a9681208c
|
|
| MD5 |
c782e174caef155e32d786640d791ed0
|
|
| BLAKE2b-256 |
4e273fc4bb4888e4fe8353f31de40a68f2e1a0275fdc39c171d4b548140fef44
|
Provenance
The following attestation bundles were made for opteryx_access-0.1.0.tar.gz:
Publisher:
release.yaml on mabel-dev/opteryx-access
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
opteryx_access-0.1.0.tar.gz -
Subject digest:
50f81bcc4bf7e852363c3a83d0991d5f496c173605d5ca932b491f3a9681208c - Sigstore transparency entry: 2433615542
- Sigstore integration time:
-
Permalink:
mabel-dev/opteryx-access@7904005055c790aa83119fda62f3b131f1968f3e -
Branch / Tag:
refs/tags/version-0.1.0 - Owner: https://github.com/mabel-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@7904005055c790aa83119fda62f3b131f1968f3e -
Trigger Event:
push
-
Statement type:
File details
Details for the file opteryx_access-0.1.0-py3-none-any.whl.
File metadata
- Download URL: opteryx_access-0.1.0-py3-none-any.whl
- Upload date:
- Size: 41.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31bb49a332622c751e61d76d5ca3778d02d79a6fde10b3bf0b33bd68fbd04949
|
|
| MD5 |
be0c503375d990cae7783fe306216eee
|
|
| BLAKE2b-256 |
63503c5bab3ef6ae0598846416a5e70a4dac14ecec9449664917c71201ee47aa
|
Provenance
The following attestation bundles were made for opteryx_access-0.1.0-py3-none-any.whl:
Publisher:
release.yaml on mabel-dev/opteryx-access
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
opteryx_access-0.1.0-py3-none-any.whl -
Subject digest:
31bb49a332622c751e61d76d5ca3778d02d79a6fde10b3bf0b33bd68fbd04949 - Sigstore transparency entry: 2433615751
- Sigstore integration time:
-
Permalink:
mabel-dev/opteryx-access@7904005055c790aa83119fda62f3b131f1968f3e -
Branch / Tag:
refs/tags/version-0.1.0 - Owner: https://github.com/mabel-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@7904005055c790aa83119fda62f3b131f1968f3e -
Trigger Event:
push
-
Statement type: