Permissions
Getting into a project is one question. Being allowed to change what is in it is another, and namespace permissions are the answer to the second one. They decide who may create, replace, or drop persisted features, and in which parts of the namespace tree.
This is a managed-registry capability. Embedded and local registries stay single-user and never seed or enforce policy.
Three privileges, and one that does not exist
After access management admits a principal, policy controls exactly three things:
WRITE_FEATURE— create, replace, or alter a persisted feature.DELETE_FEATURE— drop one.MANAGE_PERMISSIONS— administer policy for a namespace subtree.
There is deliberately no READ_FEATURE. Every project member can query features, read formulas, and follow lineage. A read restriction that only covered direct access would be theatre: composed features and generated SQL expose the formulas of everything they depend on. If you need genuine read confidentiality, you need a separate project — or a threat model that goes well beyond this page.
Note also what authorization is not. It decides whether an operation may be attempted. It never waives FeatureQL validation, type invariants, project isolation, referential integrity, or the dependency graph. A permitted write of a broken feature still fails.
A new project is open, and says so
The resolver denies by default: no matching rule means no. Yet a fresh project is immediately collaborative, because project creation persists real policy rather than a hidden exception:
- the
PUBLICsubject, covering every human and service account in the project; - the root namespace anchor
FM; ALLOW PUBLIC WRITE_FEATURE ON FM;ALLOW PUBLIC DELETE_FEATURE ON FM.
Rules on FM reach every descendant, so everyone can work everywhere until you say otherwise. Writing this down as policy features instead of hard-coding an implicit allow has two consequences worth knowing: you can inspect the default like any other rule, and if policy ever goes missing the project fails closed rather than open.
Project OWNER and ADMIN are a second, independent source of policy authority. They can repair anything in the project — but they get no implicit write or delete. Remove the root allows and an owner must restore them, or grant themselves the privilege, before they can write again.
MANAGE_PERMISSIONS behaves the same way. Holding it does not satisfy a write check; it lets you grant yourself the write, inside your subtree. The extra step is not a security barrier — it is an audit trail, and the registry records who created that rule.
Granting and revoking
Day to day you use three verbs. They are sugar that compiles 1:1 into FM.POLICY.* features, so there is no second policy model hiding behind them.
ALLOW WRITE_FEATURE ON NAMESPACE FM.FINANCE TO USER 'alice@example.com';
ALLOW ROLE FEATURE_MUTATOR ON NAMESPACE FM.FINANCE TO GROUP analysts;
DENY WRITE_FEATURE ON NAMESPACE FM.FINANCE.PROD TO PUBLIC;
REVOKE DENY WRITE_FEATURE ON NAMESPACE FM.FINANCE.PROD FROM PUBLIC;
REVOKE ALLOW WRITE_FEATURE ON NAMESPACE FM.FINANCE FROM USER 'alice@example.com';
A few conventions carry the whole surface:
ALLOWandDENYare both first-class. A deny is a rule you create, not the absence of an allow.- Revoking always names the effect:
REVOKE ALLOWorREVOKE DENY, never a bareREVOKE. - Subjects are
TO USER '…',TO GROUP …, orTO PUBLIC— andFROM …when revoking. Users are string literals (emails or aliases), not bare identifiers. ROLEbefore a name expands a bundle of privileges:ALLOW ROLE FEATURE_MUTATOR ….- Namespace anchors and
USERlinks materialize as data when a rule needs them. Materializing one grants nothing, and it does not confer authority inside the same batch. REVOKEmatches on the normalized four-tuple, so it works whether the rule was written as sugar or by hand.
These statements are missing from the playground examples on this site for a reason: the playground runs as a shared principal with no MANAGE_PERMISSIONS, so a single DENY … TO PUBLIC would lock every other visitor out. Try one there and you get the same UE/NAMESPACE-PERMISSION-DENIED any unprivileged principal would.
Seeing what is in force
SHOW PERMISSIONS AT FM.FINANCE.PROD;
SHOW PERMISSIONS AT FM.FINANCE.PROD FOR 'alice@example.com';
SHOW PERMISSIONS;
SHOW RAW PERMISSIONS;
SHOW PERMISSIONS AT path is the one you will use: it reports what the resolver actually decides at a concrete path, with the rules that decided it. FOR 'someone' asks the same question about another principal. Bare SHOW PERMISSIONS is a compact summary of your own maximal scopes — not a path tree you can filter with LIKE. SHOW RAW PERMISSIONS lists the authored rule features instead of the decision.
Effective output names the principal, privilege, resolved namespace, effect, both specificity scores, and every decisive source rule. The scores read like this:
SUBJECT_SPECIFICITY—2for a directUSER,1for a matchingUSER_GROUP,0forPUBLICor no decisive rule.NAMESPACE_SPECIFICITY— the number of dot-separated segments in the decisive rule's namespace (FMis1,FM.A.Bis3), and0when nothing matched and the default deny applies.
Who may look: everyone can inspect their own scopes. A manager of FM.A can inspect any principal within FM.A and below. OWNER and ADMIN can inspect the whole project. Raw policy features are readable by any project member anyway, since reads are unrestricted — these views are ergonomics, not a confidentiality boundary.
Subjects and sets
CREATE USER 'alice@example.com'
WITH position = 'analyst', location = 'paris';
CREATE USER IF NOT EXISTS 'sa_batch';
ALTER USER 'alice@example.com' SET position = 'staff', location = 'berlin';
DROP USER 'alice@example.com';
CREATE USER_GROUP analysts AS 'alice@example.com', 'bob@example.com';
ALTER USER_GROUP analysts ADD 'charlie@example.com';
ALTER USER_GROUP analysts REMOVE 'bob@example.com';
DROP USER_GROUP analysts;
CREATE NAMESPACE_GROUP prod_set AS FM.FINANCE.PROD, FM.FINANCE.CORE;
ALTER NAMESPACE_GROUP prod_set ADD FM.FINANCE.STAGING;
DROP NAMESPACE_GROUP prod_set;
DROP NAMESPACE FM.FINANCE;
A USER is a link to a principal that already exists in the project — humans resolve by email through the access directory, service accounts by project alias. Creating the link grants nothing at all. A scoped manager may materialize a missing one, but only OWNER or ADMIN can delete a project-global USER, and only once nothing references it.
Groups are live sets, and may nest. Cycles are rejected. Editing a group that rules depend on is itself a policy mutation, audited with the membership before and after, every referencing permission, and the effective scopes the edit moved.
There is no CREATE OR REPLACE USER and no group rename in v1 — a group's identity is its name.
Privileges and roles are immutable system features: their names map to code-enforced behavior, so nobody in a project can create, replace, or delete them. A role expands into its member privileges without changing how specific the rule is.
Namespaces are paths, not declarations
A NAMESPACE feature is a policy anchor, not something the feature graph requires. For FM.A.B.REVENUE, the target of authorization is the literal path FM.A.B.
So a writer can create a feature in a child path nobody has ever mentioned, as long as an inherited WRITE_FEATURE covers it — no anchor, no MANAGE_PERMISSIONS needed. You only need an anchor when policy has to target that path or put it in a namespace group.
One subtlety: authorization for a policy mutation always uses the governed path stored in the anchor or rule, never the storage path of the policy feature under FM.POLICY. Writing a rule about FM.PROD requires authority over FM.PROD.
When rules disagree
Roles and groups expand into atomic rules first. Identical expansions are deduplicated for the decision, while every source rule is kept for diagnostics.
Specificity then has two dimensions:
- Subject — a direct
USERbeats anyUSER_GROUP, which beatsPUBLIC. All matching groups are equally specific. - Namespace — the exact target beats the longest matching ancestor. A namespace-group member counts the same as a direct anchor at that path.
A rule dominates another only when it is at least as specific in both dimensions and strictly more specific in one. Dominated rules drop out. If several incomparable rules survive, or equally specific ones conflict, DENY wins.
Take three rules:
ALLOW PUBLIC WRITE_FEATUREonFMDENY PUBLIC WRITE_FEATUREonFM.PRODALLOW Alice WRITE_FEATUREonFM.PROD
Rule 2 beats rule 1 on namespace. Rule 3 beats rule 2 on subject, at the same namespace. Alice writes to production; nobody else does.
Now cross the dimensions — allow Alice on FM.CROSSED, deny PUBLIC on FM.CROSSED.CORE. Her rule is more subject-specific, the deny is more namespace-specific, neither dominates, and so Alice is refused in FM.CROSSED.CORE. When in doubt, the stricter rule wins.
| Rule A compared with rule B | Result |
|---|---|
| At least as specific in both dimensions, stricter in one | A dominates B |
| Less specific in both dimensions | B dominates A |
| More subject-specific but less namespace-specific | Incomparable; DENY wins if either is a deny |
| Less subject-specific but more namespace-specific | Incomparable; DENY wins if either is a deny |
| Equal in both, same effect | Deduplicated, all sources retained |
| Equal in both, conflicting effects | DENY wins |
Diagnostics return every decisive atomic rule and all contributing source feature names, in a stable order — so "why was I refused?" has an exact answer.
Delegating a subtree
Grant MANAGE_PERMISSIONS on FM.A and its holder becomes the policy owner of that subtree: namespace anchors, namespace groups, and rules for FM.A and everything below it. They can delegate further, but only inside it — a rule pointing at FM.OUTSIDE is refused, and so is a namespace group that reaches out of the subtree.
Inside their subtree the delegation is genuinely full, including removing a rule a project administrator created. That is intentional: a subtree owner who cannot undo an inherited deny does not really own the subtree.
Project-wide user groups stay with OWNER and ADMIN. A delegated manager may materialize a missing USER link, but not delete one.
Under the hood: FM.POLICY
Sugar expands into persisted features under the reserved tree FM.POLICY: USERS, USER_GROUPS, NAMESPACES, NAMESPACE_GROUPS, PRIVILEGES, ROLES, and RULES.
Policy functions are valid only in their own subtree, and ordinary features cannot be persisted anywhere under FM.POLICY — even with root WRITE_FEATURE. It is storage, not a governed business namespace, and it cannot be the target of a permission.
A rule is the normalized four-tuple:
- one
USERorUSER_GROUP; - one system
PRIVILEGEorROLE; - one
NAMESPACEorNAMESPACE_GROUP; ALLOWorDENY.
Identity comes from that tuple, not the feature name, so the same tuple cannot be persisted twice under two names. ALLOW and DENY are different tuples and may coexist — which is exactly why revoking a deny can reveal an allow that was there all along.
Writing rules by hand still works, and is sometimes clearer than a hashed sugar leaf. Sugar emits flat names like FM.POLICY.RULES.ALLOW_<hash>; nesting readable names under a type root is an authoring convenience. System privileges and roles are referenced, never recreated:
CREATE OR REPLACE FEATURES AS
PERSIST
FM.POLICY.USERS.ALICE := USER(
'95f7a980-57cf-4f5d-befe-9f0bd230ea25',
'alice@example.com',
'analyst',
'paris'
),
FM.POLICY.NAMESPACES.PROD := NAMESPACE('FM.PROD'),
FM.POLICY.RULES.ALLOW_6C44E93B := PERMISSION(
FM.POLICY.USERS.ALICE,
FM.POLICY.PRIVILEGES.WRITE_FEATURE,
FM.POLICY.NAMESPACES.PROD,
'ALLOW'
);
A refused mutation returns UE/NAMESPACE-PERMISSION-DENIED — a FeatureQL diagnostic, not an access-management error.
Integrity rules worth remembering
Revocation is exact, and order matters:
- allow
Pto user A on namespace X; - deny
Pto A on X — now there are two rules; - revoke the deny, and the allow reappears;
- revoke the allow;
- revoke either again and you get an error.
Dropping a user, group, namespace, role, or privilege that something still references is rejected unless the references go in the same authorized transaction.
And every item in a mutation batch is authorized against the policy as it stood before the batch. A grant you create in a batch cannot authorize another item in that same batch — no bootstrapping yourself into a write.
Next
- Permission statement syntax — exhaustive grammar for users, groups, rules, and permission inspection
- Access management — who gets into the project in the first place
- Managing access from Python — orgs, invitations, tokens, service accounts
- Feature lifecycle — the other thing the registry enforces across the graph