teamrole

package
v1.31.26 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: Apache-2.0 Imports: 2 Imported by: 0

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

View Source
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

View Source
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):

  1. Target must be a managed catalog role (else ErrNotManaged — not our job).
  2. A platform superuser may do anything (returns nil early).
  3. 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.
  4. 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.
  5. 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

func EffectiveRank(callerKeys []string, targetApp App) int

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

func IsManaged

func IsManaged(roleName string) bool

IsManaged reports whether a role name (the IAM Role.Name) is a managed catalog role. Role.Name is stored as the exact catalog Key.

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

const (
	AppBilling App = "billing"
	AppConsole App = "console"
	// AppOrg is the org-wide surface. Only org:owner lives here; it is
	// authoritative over every other app.
	AppOrg App = "org"
)

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

func AppTiers(app App) []Role

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

func Lookup(key string) (Role, bool)

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.

func (Role) String

func (r Role) String() string

String renders a role key for logs/errors.

type Tier

type Tier string

Tier is the privilege level within an app.

const (
	TierViewer Tier = "viewer"
	TierAdmin  Tier = "admin"
	TierOwner  Tier = "owner"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL