Files
Trusty/docs/scope-grammar.md

3.4 KiB

Scope grammar

The string format trusty_policy.permits() matches against. Two repos depend on it, so it is written down here rather than inferred from the implementation.

The rule

CREATE OR REPLACE FUNCTION trusty_policy.covers(scope TEXT, resource TEXT)
RETURNS BOOLEAN
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $fn$
    SELECT scope IS NOT NULL
       AND resource IS NOT NULL
       AND (resource = scope OR starts_with(resource, scope || '/'))
$fn$

A scope covers a resource when they are equal, or when the resource begins with the scope followed by a /.

That is the whole grammar. There are no wildcards, no globs, and no pattern syntax — deliberately.

Shape

<class>:<id>[/<segment>]*
part example rule
class board opaque to trusty
id 9f2 opaque to trusty
segment calendar, private separated by /

Only the / is load-bearing. <class>:<id> is a convention the consumer chooses; trusty treats everything before the first / as one opaque string.

What follows from it

/ is the only separator. board:9f2/calendar covers board:9f2/calendar/private. It does not cover board:9f2/calendar-private, because the character after the scope is -, not /.

A near-miss prefix does not match. board:o does not cover board:other/calendar. Without the separator in the concatenation it would, which is why the separator is in there and not an afterthought.

No wildcards. A scope of % is a scope literally named %, and a scope containing _ matches only a literal underscore. starts_with is used rather than LIKE precisely so an attacker-influenced scope cannot widen itself into a pattern.

Case and Unicode are exact. Board:9f2 is a different scope from board:9f2. There is no normalisation, folding, or trimming anywhere in the path. Emit exactly what you mean to match.

No trailing slash. board:9f2/calendar/ is a different scope from board:9f2/calendar, and covers board:9f2/calendar//private rather than anything useful. Do not emit one.

Exclusions use the same rule. An exclusion of board:9f2/calendar/private shuts that resource and everything beneath it, checked before coverage and across every live grant of the subject regardless of tier.

What a producer must guarantee

Anything writing a resource_scope column, or issuing a grant, owes the following. Trusty cannot check them, because the parts are opaque to it:

  1. Segments are emitted in the same shape both sides expect. An exclusion of board:9f2/calendar/private protects nothing if rows carry board/9f2/calendar/private.
  2. No segment contains a / of its own. A segment that does silently becomes two levels, and a grant one level up now covers it.
  3. Ids are not user-controlled free text. board:<user-supplied> lets someone name a board 9f2/calendar and inherit a scope they were never granted.

Point 3 is the one that bites. If ids can contain /, the grammar has no boundary and coverage becomes forgeable.

What trusty deliberately does not do

  • Parse, validate or normalise a scope
  • Attach meaning to class or id
  • Enforce a maximum depth

The engine takes two opaque strings and asks whether one contains the other. Keeping it that narrow is what lets the consumer own the namespace — and is why the guarantees above are the consumer's to keep.