Melange v0.9.5
Melange v0.9.5 fixes an exclusion bypass in generated SQL: a user excluded by a but not relation was allowed through any relation that reached it via an alias or union. It also fixes contextual tuples in the Go runtime reading grants from another schema or another session, list functions that disagreed with check_permission, and commands that exited 0 after a failed write.
melange migrate with the v0.9.5 CLI to replace the generated SQL, and upgrade github.com/pthm/melange/melange for the contextual-tuple fixes. Each fixes different issues. A released binary re-applies the changed functions without --force. Tuples need no migration.Affected configurations
| Issue | Affected when | Effect |
|---|---|---|
| Exclusion bypass | A relation reaches a but not relation through an alias or union | Excluded users allowed |
| Exclusion scope | A union branch and its enclosing relation both have exclusions | Valid grants denied |
| List leaks | As for the bypass, in list_accessible_objects / list_accessible_subjects | Excluded users listed |
| Contextual tuples: schema | Go *WithContextualTuples, more than one melange_tuples visible to the role | Grants read from another schema |
| Contextual tuples: concurrency | Concurrent Go *WithContextualTuples calls | Non-superuser: SQLSTATE 42501. Superuser: another request’s grants applied |
| Userset subjects in lists | list_accessible_objects for a userset subject on a self-referential or recursive TTU relation | Objects missing |
The exclusion bypass is confirmed in v0.9.3 and v0.9.4. Earlier versions have not been tested.
Exclusion bypass
type document
relations
define owner: [user]
define writer: [user]
define blocked: [user]
define reader: writer but not blocked
define can_read: owner or readerWith a writer and a blocked tuple for Alice:
| Check | Expected | v0.9.4 | v0.9.5 |
|---|---|---|---|
alice reader doc | deny | deny | deny |
alice can_read doc | deny | allow | deny |
Relation closure expanded can_read through reader to writer, so check_document_can_read matched relation IN ('can_read', 'owner', 'writer') and never evaluated reader’s exclusion. define alias: reader alone was enough to trigger it.
Closure expansion now stops at relations with an exclusion (relation, parent relation, or excluded intersection). The enclosing relation calls that relation’s check function.
Exclusion scope
define reader: a but not b
define can_read: reader or c
define final: can_read but not dA user with b and c should have final. Exclusions from closure relations were applied to the enclosing relation, so reader’s but not b also applied to final and denied them. Each relation now applies only its own exclusions.
List functions
Checks were correct after the fixes above. These list paths were not:
- Userset and TTU patterns inherited from an excluded closure relation were expanded without its exclusion. Example:
reader: [group#member] but not blockedreached through an alias listed blocked group members. - The recursive list_subjects strategy dropped inline exclusions under an anti-join optimisation that only the standard strategy emits. Affects exclusions over unions mixing a userset, a TTU, and a direct or implied branch. This predates v0.9.3.
X from parent, where the parent’sXaliases an excluded relation, looked up tuples on the parent’s expanded relations and listed excluded viewers.- Self-referential usersets (
member: [user, group#member] or eligible,eligible: candidate but not banned) listed banned members of nested groups.
Each path now composes or validates through the excluded relation’s list function.
Contextual tuples
*WithContextualTuples creates a temporary melange_tuples view over the permanent relation. The runtime found the permanent relation by querying pg_class, sorting by schema name, and taking the first match.
- Schema.
WithDatabaseSchema("public")could selecta_other_dim.melange_tuples. Grants in that schema then applied to contextual checks. This requires the role to have access to both schemas. - Concurrency. Temporary views are visible in
pg_classto all sessions, so request B could select request A’s contextual view as its base. As a non-superuser B failed withpermission denied for schema pg_temp_N. As a superuser B was evaluated with A’s contextual grants.
The configured schema is now used exclusively, temporary relations are excluded, and a missing relation returns ErrNoTuplesTable.
Userset subjects in list_objects
| Relation | Subject | v0.9.4 | v0.9.5 |
|---|---|---|---|
member: [user, group#member] or owner | group:inner#member | inner | inner, outer, top |
viewer: [user, group#member] or viewer from parent | group:g#member | none | root, child, grandchild |
| same | folder:root#viewer | root | root, child, grandchild |
The self-referential and recursive TTU strategies did not match userset subjects in their base case, and added the subject’s own object after the recursive step instead of seeding it. Both are fixed. In the TTU strategy the subject’s object propagates only if its relation satisfies the inherited relation.
The generated functions branch on whether the subject is a userset, so plain-subject queries are unchanged from v0.9.4.
Write errors
migrate --dry-run, diff, schema pull, config show, status --format json, and expand --recursive / --flatten ignored write errors. Truncated output on a closed pipe or full disk exited 0, so diff --exit-code could miss a breaking change and migrator.Migrate with DryRun could return nil after partial output. Write errors are now returned.
Behaviour change
With WithDatabaseSchema(""), the contextual tuple source is resolved through search_path on each request instead of cached. A configured schema is still cached. The default is public.
Performance
The GitHub-style test schema and the kitchen-sink schema generate the same SQL as in v0.9.4. Only models that reach an excluded relation through an alias or union change.
On the 14 test models whose SQL changed, check and list latency fell 17.9% (geometric mean). Two operations are slower, measured on small fixtures:
| Operation | v0.9.4 → v0.9.5 |
|---|---|
list_accessible_subjects with a userset filter, on an alias of an excluded relation | ~280µs → ~740µs |
check_permission on an alias of an excluded relation | +10–57% |
Both previously returned incorrect results. Kitchen-sink list_accessible_objects benchmarks are unchanged.
Testing
The OpenFGA conformance suite did not catch the bypass: its closest fixture, exclusion_for_some_relations, never added an excluded user.
TestOpenFGA_GeneratedAlgebra generates models combining aliases, unions, intersections, exclusions, and parent inheritance, and compares check_permission with an in-process OpenFGA server across all 128 assignments of seven tuples per model. FuzzAuthorizationAlgebra mutates and shrinks inputs. It found the scope bug.
YAML fixtures cover each fix for check, list objects, and list subjects, with expectations verified against OpenFGA (MELANGE_OPENFGA_ORACLE=1). Go integration tests cover the contextual-tuple fixes, including non-superuser roles and concurrent sessions.
Credits
Nick DiRienzo reported the exclusion bypass, the scope bug, and both contextual-tuple issues, with reproductions and patches, including the differential test.
Try it out
# Install / upgrade CLI
brew install pthm/melange/melange
# Or pull the container image
docker pull ghcr.io/pthm/melange:v0.9.5
# Replace the generated SQL
melange migrate
# Go runtime (contextual-tuple fixes)
go get github.com/pthm/melange/melange@v0.9.5
# TypeScript runtime
npm install @pthm/melangeFeedback
Report security issues through private vulnerability reporting. Open an issue for bugs or feature requests.
