Multi-tenancy
In wardn, tenants are simply the top level of your scope tree. Isolation is not a feature you switch on: a role assigned in one tenant has no path to another.
-
Declare the tree
wardn.yaml scopes:organisation: {}branch: { parent: organisation } -
Link scopes when you create them
func (s *Service) CreateBranch(ctx context.Context, orgID, branchID string) error {if err := s.db.InsertBranch(ctx, orgID, branchID); err != nil {return err}return s.wardn.Link(ctx,wardn.NewScope("branch", branchID),wardn.NewScope("organisation", orgID))}Calling
Linkagain with the same parent does nothing, so it’s safe on every start. To move a branch to another organisation, callw.Moveinstead; inheritance follows at once. -
Assign at the right level
w.Assign(ctx, "ada", "org_admin", wardn.NewScope("organisation", "acme")) // all of Acmew.Assign(ctx, "bayo", "teller", wardn.NewScope("branch", "lagos")) // one branch -
Scope every request
r.Route("/orgs/{orgID}", func(r chi.Router) {r.Use(w.ScopeFrom("organisation", wardn.PathValue("orgID")))// ...r.Route("/branches/{branchID}", func(r chi.Router) {r.Use(w.ScopeFrom("branch", wardn.PathValue("branchID")))// ...})})
Already have the hierarchy in your database?
Section titled “Already have the hierarchy in your database?”Instead of calling Link, let wardn ask your data, so nothing is stored twice:
wardn.Config{ ParentOf: func(ctx context.Context, s wardn.Scope) (wardn.Scope, bool, error) { switch s.Type { case "branch": org, err := repo.OrganisationOfBranch(ctx, s.ID) if errors.Is(err, sql.ErrNoRows) { return wardn.Scope{}, false, nil } return wardn.NewScope("organisation", org), err == nil, err } return wardn.Scope{}, false, nil },}ParentOf must return the true parent from authoritative data. If it returns an error, the check denies.
The confused-deputy trap
Section titled “The confused-deputy trap”A URL says which tenant a request claims to act in. A record says which tenant it belongs to. They aren’t always the same. There are two safe patterns:
- Load records only through queries filtered by the URL’s scope (
WHERE branch_id = ? AND id = ?). A record from another tenant simply isn’t found. This is the simplest, and the usual choice. - Check against the record’s own scope, when the record is loaded by ID alone:
// POST /orgs/acme/loans/987/approve, but loan 987 belongs to globex.func (h *Handlers) approveLoan(rw http.ResponseWriter, r *http.Request) { loan := h.loans.Get(r.Context(), r.PathValue("id")) userID, _ := wardn.UserOf(r.Context()) if !h.wardn.Can(r.Context(), userID, "loan:approve", loan.Scope()) { // the record's own scope http.Error(rw, "forbidden", http.StatusForbidden) return } // ...}In both patterns the permission check comes first, as the next note explains.
What wardn guarantees
Section titled “What wardn guarantees”- An assignment applies only in its scope and below. wardn re-checks every row the store returns, so even a faulty store can’t leak another tenant’s roles.
- Custom roles are visible and assignable only inside the tenant that owns them.
- A scope that was never linked inherits nothing except global roles.
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.