Scopes and hierarchy
A scope is where a role applies. A scope has a type and an ID: organisation:acme, branch:lagos, workspace:w42.
Your tree, your types
Section titled “Your tree, your types”wardn has no built-in hierarchy. Each product declares its own scope types and how they nest:
scopes: organisation: {} region: { parent: organisation } branch: { parent: region }scopes: workspace: {} project: { parent: workspace }scopes: school: {} class: { parent: school }# No scopes at all: every role is assigned at global.Above every tree sits one implicit scope: global. A role assigned at global applies everywhere.
Roles flow down
Section titled “Roles flow down”A role assigned at a scope applies in that scope and in every scope beneath it, never above it and never sideways:
global root → super_admin (applies everywhere) ├── organisation:acme ada → org_admin (acme and all below) │ └── region:south seun → auditor (south and its branches) │ ├── branch:lagos bayo → teller (lagos only) │ └── branch:ibadan └── organisation:globex (ada has nothing here)Telling wardn which branch belongs where
Section titled “Telling wardn which branch belongs where”The policy says a branch’s parent is a region. wardn must also learn that branch lagos sits in region south. There are two ways.
Store the links (default). Call Link when your application creates or moves an entity:
err := w.Link(ctx, wardn.NewScope("branch", "lagos"), wardn.NewScope("region", "south"))Or resolve parents from your own data, so nothing is stored twice:
wardn.Config{ ParentOf: func(ctx context.Context, s wardn.Scope) (wardn.Scope, bool, error) { if s.Type == "branch" { region, err := branches.RegionOf(ctx, s.ID) return wardn.NewScope("region", region), err == nil, err } // ... other types return wardn.Scope{}, false, nil },}Either way, wardn checks that the parent has the type the policy declares.
To move a branch to another region later, call w.Move(ctx, branch, newRegion). Link never changes an existing parent: linking to the same parent again does nothing, and linking to a different one fails with ErrAlreadyLinked.
Scope IDs must be unique per type
Section titled “Scope IDs must be unique per type”A scope is identified by its type and ID alone, across all tenants. If two hospitals each call a department cardiology, wardn sees one scope, department:cardiology.
- Use IDs that are unique across tenants: your database’s primary keys or UUIDs, or composite IDs such as
lagos-general/cardiology. - wardn refuses the dangerous case. Linking a second hospital’s
cardiologywould move the first hospital’s department under the second hospital’s administrators, so it fails withErrAlreadyLinkedinstead.
With composite IDs, give ScopeFrom a function that builds the ID from the URL:
departmentID := func(r *http.Request) string { return r.PathValue("hospitalID") + "/" + r.PathValue("deptID")}r.Use(w.ScopeFrom("department", departmentID))Safety rules
Section titled “Safety rules”- A scope whose parent is unknown inherits only from global. If a branch was never linked, an organisation admin has no power there. This fails safe.
- IDs are compared exactly.
o1andO1are different scopes; wardn never normalises IDs. IDs may be up to 256 bytes and may not contain control or invisible characters, or start or end with whitespace. - Lookups are bounded by the depth of your scope tree, so corrupt data can never cause an endless loop.
- A check with no scope happens at global, where only global assignments apply. That is always the most restrictive answer, never the most permissive.
Questions about this page?
Ask below, point out something wrong or unclear, or suggest an improvement. Reading needs no account; to post, sign in with GitHub.