Documentation
¶
Overview ¶
Package teamrole is the canonical, DB-free policy for app-scoped team roles.
It defines the ONLY roles the shared Team-management surface (billing.hanzo.ai, console.hanzo.ai, and any future product on Hanzo IAM) may grant, change, or revoke, and the pure authorization function that decides whether a given caller is allowed to do so.
The policy is deliberately decomplected from persistence: nothing here reads the database. The IAM `object` package supplies the caller's held role keys and the target role's owner, then calls CheckAssignment. This makes the security-critical decision a pure value-function — trivially unit-testable, deterministic, and free of I/O — which is exactly what an adversarial reviewer needs to reason about.
Why this exists: IAM roles/permissions live co-mingled on the GLOBAL SQLite engine, keyed only by the `Owner` (org) column — there is no per-tenant cryptographic isolation for roles. The generic Casbin authz filter only gates "may this subject act on this org at all"; it does NOT encode app-scope or rank. Without this policy a billing:admin could call update-role to grant itself console:admin or org:owner in its own org (vertical + lateral privilege escalation), and nothing but the Owner-column would stop a crafted request from targeting another org (cross-tenant member manipulation). This package closes both holes.
Index ¶
- Constants
- Variables
- func AssignableKeys(callerKeys []string, callerOrg string, ...) []string
- func CanAssign(a Assignment) bool
- func CheckAssignment(a Assignment) error
- func EffectiveRank(callerKeys []string, targetApp App) int
- func IsManaged(roleName string) bool
- type App
- type Assignment
- type Role
- type Tier
Constants ¶
const RankAdmin = 20
RankAdmin is the minimum effective rank required to manage team membership in an app. Below this (viewer) a caller may read but never mutate membership.
Variables ¶
var ( // ErrNotManaged means the target role is not in the catalog — the caller // should fall through to ordinary authz (this is not a team operation). ErrNotManaged = errors.New("teamrole: not a managed team role") // ErrCrossOrg means the caller tried to manage a role in a different org. ErrCrossOrg = errors.New("teamrole: cross-org role management denied") // ErrInsufficientAuthority means the caller lacks admin+ authority in the // target role's app. ErrInsufficientAuthority = errors.New("teamrole: insufficient authority in app") // ErrRankCeiling means the target role outranks the caller's authority. ErrRankCeiling = errors.New("teamrole: target role exceeds caller rank ceiling") // ErrMissingOrg means an org identifier was empty — fail closed. ErrMissingOrg = errors.New("teamrole: missing org identifier") )
Denial reasons. These are stable sentinels so callers (and tests) can branch on the CAUSE without string matching. The human message rides alongside via the wrapping error; the wire only ever shows a generic "Unauthorized operation" to avoid leaking which barrier tripped.
Functions ¶
func AssignableKeys ¶
func AssignableKeys(callerKeys []string, callerOrg string, callerIsGlobalAdmin, callerIsOrgAdmin bool) []string
AssignableKeys returns the catalog keys a caller is permitted to assign in their own org — i.e. every catalog role that passes CheckAssignment for the SAME org. The client renders exactly these in the role picker. Order matches the canonical catalog (by rank).
func CanAssign ¶
func CanAssign(a Assignment) bool
CanAssign reports, for UX mirroring, whether a caller holding callerKeys in callerOrg could assign targetKey in targetOrg. It is CheckAssignment reduced to a bool; both share one implementation so the client and server can never drift. Server code should call CheckAssignment for the error detail + audit.
func CheckAssignment ¶
func CheckAssignment(a Assignment) error
CheckAssignment is THE server-side authorization gate for managing a team role. It returns nil iff the caller may grant/change/revoke a.TargetKey, and otherwise a wrapped sentinel error (see Errors above) whose text is safe to log. It MUST be called before every AddRole/UpdateRole/DeleteRole and every invitation that targets a managed catalog role.
The checks, in order (fail closed on the first that trips):
- Target must be a managed catalog role (else ErrNotManaged — not our job).
- A platform superuser may do anything (returns nil early).
- Orgs must be present and equal — no cross-tenant management (ErrCrossOrg). This is the sole barrier against cross-org member manipulation because roles are co-mingled on the global db keyed only by Owner.
- The caller must hold admin+ authority IN the target role's app (ErrInsufficientAuthority). Viewers manage nothing; app scope is enforced because EffectiveRank is 0 for an app the caller has no role in.
- The target role must not outrank the caller's effective authority (ErrRankCeiling). This blocks vertical escalation (billing:admin → org:owner) and, combined with (3), lateral escalation (console:admin → billing:*). Equal rank is permitted so an admin can delegate their own tier to a teammate.
func EffectiveRank ¶
EffectiveRank returns the caller's maximum authority rank for a target app, given the catalog role keys they hold. An org:owner is authoritative over EVERY app and short-circuits to its full rank; otherwise the result is the highest rank among the caller's roles that belong to targetApp. A caller with no role in the app gets 0 (no authority) — which is precisely why a console:admin has zero power over billing.
Exported because the client mirrors it to gray out un-grantable options in the role picker (UX only; this server-side function is the authority).
Types ¶
type App ¶
type App string
App is a product surface that scopes a role. A caller with authority in one app has NO authority in another (org owners excepted).
type Assignment ¶
type Assignment struct {
// CallerKeys are the catalog role keys the caller currently holds *within
// CallerOrg*. Keys not in the catalog are ignored. The object package
// derives this from the caller's authenticated identity; a client cannot
// influence it.
CallerKeys []string
// CallerOrg is the caller's org, taken from the authenticated User.Owner —
// NEVER from the request body.
CallerOrg string
// CallerIsGlobalAdmin is true only for a platform superuser
// (User.IsGlobalAdmin via session/JWT, never a client-supplied flag).
CallerIsGlobalAdmin bool
// CallerIsOrgAdmin is true when the caller is an ADMIN of CallerOrg
// (User.IsAdmin). An org admin has org-wide authority WITHIN THEIR OWN ORG:
// IAM's authz filter already vets them to reach a role mutation, and they
// own their org's team. It grants no cross-org power — the org-equality
// check below still fully applies. Never a client-supplied flag.
CallerIsOrgAdmin bool
// TargetOrg owns the role being managed (IAM Role.Owner). For an invitation
// it is the org the invite is scoped to.
TargetOrg string
// TargetKey is the catalog key of the role being granted, changed, or
// revoked ("app:tier").
TargetKey string
}
Assignment fully describes a proposed team-role mutation for the guard. Using a struct (not positional args) makes the call sites self-documenting and removes the classic "swapped callerOrg/targetOrg" footgun that is itself a cross-tenant vulnerability.
type Role ¶
type Role struct {
Key string `json:"key"` // "app:tier", e.g. "billing:admin"
App App `json:"app"` // billing | console | org
Tier Tier `json:"tier"` // viewer | admin | owner
Rank int `json:"rank"` // strict order: viewer<admin<console-owner<org-owner
Resource string `json:"resource"` // Casbin resource this role grants on, e.g. "billing:*"
Actions []string `json:"actions"` // Casbin actions this role grants
DisplayName string `json:"displayName"` // human label for the UI
Description string `json:"description"` // human help text for the UI
}
Role is one entry in the canonical catalog. Key is the stable identifier used everywhere (IAM Role.Name, the client role picker, and the wire): it is the exact "app:tier" string the CTO specified — billing:viewer, billing:admin, console:viewer, console:admin, console:owner, org:owner.
func AppTiers ¶
AppTiers returns the catalog roles for one app, ordered by rank ascending. The client role picker uses this to render only the tiers that exist for the surface it is mounted on (billing shows viewer/admin; console shows viewer/admin/owner).
func Catalog ¶
func Catalog() []Role
Catalog returns a defensive copy of the canonical role catalog, ordered by rank. Callers (e.g. the seed path, or an API that lists assignable roles) get a fresh slice they cannot use to mutate policy.
func Lookup ¶
Lookup returns the catalog role for an "app:tier" key and whether it is a managed team role. Unknown keys (any role name not in the catalog) return ok=false, which is how the IAM controllers decide a role mutation is NOT team-managed and falls through to the ordinary authz path unchanged.