Skip to content

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.

  1. Declare the tree

    wardn.yaml
    scopes:
    organisation: {}
    branch: { parent: organisation }
  2. 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 Link again with the same parent does nothing, so it’s safe on every start. To move a branch to another organisation, call w.Move instead; inheritance follows at once.

  3. Assign at the right level

    w.Assign(ctx, "ada", "org_admin", wardn.NewScope("organisation", "acme")) // all of Acme
    w.Assign(ctx, "bayo", "teller", wardn.NewScope("branch", "lagos")) // one branch
  4. 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.

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:

  1. 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.
  2. 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.

  • 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.