authz

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jul 25, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

Documentation

Overview

Package authz is the single authorization layer every surface gates through. It models the caller as a Principal (registered user, anonymous human, or non-human trigger), resolves that principal's effective per-agent access off agent_grants, and checks it against a central action→required-level policy. HTTP handlers, bridges, A2A, and the system agent all build a Principal and call Authorize — there is no second place that decides "what level does this action need".

It is a low-level package (deps: agentsdk, auth, dbq, apperr, uuid, pgtype) so both service and trigger can import it without a cycle.

Index

Constants

View Source
const (
	CapView   = "view"
	CapBind   = "bind"
	CapManage = "manage"
)

Resource capabilities (the management plane). Runtime "use" of a bound resource is intrinsic to the agent's code and is not gated here; these gate who may see, attach (bind), and reconfigure a resource.

Variables

View Source
var (
	GroupAdmin   = uuid.MustParse("00000000-0000-0000-0000-0000000000a1")
	GroupManager = uuid.MustParse("00000000-0000-0000-0000-0000000000a2")
	GroupUser    = uuid.MustParse("00000000-0000-0000-0000-0000000000a3")
)

Built-in group principal ids in the schema baseline. They let a grant target a tenant role ("all managers") without a stored-membership table: the resolver expands a caller into the role-groups it belongs to. admin ⊇ manager ⊇ user, so a higher role inherits the lower groups' grants.

Functions

func AccessAtLeast

func AccessAtLeast(a, min agentsdk.Access) bool

AccessAtLeast reports whether a ranks at or above min on the per-agent access ladder. The single comparator for that ladder — chat slash gates, the service-layer Authorize gate, and the MCP path all rank through here so the ordering can't drift between surfaces.

func Authorize

func Authorize(ctx context.Context, q *dbq.Queries, p Principal, a Action, agentID uuid.UUID) error

Authorize is the single gate. It looks the action up in the policy table and checks the principal against the required level on the action's axis. Returns:

  • apperr.ErrUnauthorized when there is no authenticated identity (a registered-user principal with a zero UserID — i.e. no JWT).
  • apperr.ErrForbidden when the principal is known but ranks below the requirement.
  • nil when allowed.

Panics on an action missing from the policy table (fail loud — a new action must not silently default to allowed). agentID is ignored for tenant-axis actions.

func AuthorizeOwnedResource

func AuthorizeOwnedResource(ctx context.Context, q *dbq.Queries, p Principal, ownerID uuid.UUID, adminAction Action) error

AuthorizeOwnedResource gates on "the caller owns the resource, OR the caller's tenant role satisfies adminAction." Use this anywhere a row has a single owner_id (bridges, platform_identities, OAuth grants) and an admin escape exists for cross-user moderation.

ownerID is the UserID stored on the resource. adminAction must be a tenant-axis Action — the policy table is the single source of truth for who can act on someone else's resource.

Returns nil if owner, otherwise the result of Authorize(adminAction). Anonymous / trigger principals fall through to Authorize, which rejects them with 401/403 as appropriate.

func AuthorizeResource

func AuthorizeResource(ctx context.Context, q *dbq.Queries, p Principal, action Action, resourceType string, resourceID uuid.UUID) error

AuthorizeResource applies the central authenticated-user policy and then the requested capability to a concrete management-plane resource.

func LockResource

func LockResource(ctx context.Context, q *dbq.Queries, resourceType string, resourceID uuid.UUID) error

LockResource serializes capability changes and credential replacement on a concrete resource. Callers lock need rows first when an operation has both.

func MinAccess

func MinAccess(a, b agentsdk.Access) agentsdk.Access

MinAccess returns the lower of two access levels on the agent ladder. It composes the A2A delegation caps: a sibling agent acting on a user's behalf can never exceed the access its own owner holds on the target, nor the per-edge max_access the operator set on the address-book entry. The effective access is the minimum across (driving user, acting-agent owner, edge max_access).

func RequiredTenantRole

func RequiredTenantRole(a Action) auth.Role

RequiredTenantRole returns the minimum tenant role for a tenant-axis action, so the router's RequireTenantRole middleware can source its level from the same policy table rather than a literal. Panics if the action is unknown or not tenant-axis (fail loud).

func ResourceCapabilities

func ResourceCapabilities(ctx context.Context, q *dbq.Queries, p Principal, resourceType string, resourceID uuid.UUID) ([]string, error)

ResourceCapabilities returns the caller's complete capability set after requiring bind access. It is used by the need picker, where bind-only resources remain visible while manage controls shared authorization changes.

Types

type Action

type Action string

Action names a gated operation. The policy map below is the single source of truth for "what level does this action require" — every surface authorizes by Action, never by a hardcoded level at the call site, so the two routes that can reach the same action can't drift.

const (
	// Agent axis — member (AccessUser) suffices.
	AgentGet          Action = "agent.get"
	AgentUpdate       Action = "agent.update"
	AgentLifecycle    Action = "agent.lifecycle" // stop / start / suspend
	AgentGit          Action = "agent.git"       // connect / disconnect / read git binding
	AgentRunView      Action = "agent.run.view"  // runs list / get / logs (admin: runs span all users)
	AgentMembersView  Action = "agent.members.view"
	AgentToolsView    Action = "agent.tools.view"
	AgentBuildsView   Action = "agent.builds.view"  // builds list / get / log stream (admin)
	AgentConversation Action = "agent.conversation" // create / list web conversations
	AgentModelsView   Action = "agent.models.view"
	AgentClone        Action = "agent.clone" // fork this agent's code into a new agent (member of source; also needs TenantAgentClone)
	AgentFileResolve  Action = "agent.file.resolve"

	// Agent axis — owner (AccessAdmin) required.
	AgentDelete            Action = "agent.delete"
	AgentBuildManage       Action = "agent.build.manage" // upgrade / rollback / cancel build
	AgentMembersManage     Action = "agent.members.manage"
	AgentWebhooksView      Action = "agent.webhooks.view"
	AgentSchedulesView     Action = "agent.schedules.view"
	AgentScheduleFire      Action = "agent.schedule.fire"
	AgentConnections       Action = "agent.connections"    // credentials / MCP / env-vars
	AgentExecEndpoints     Action = "agent.exec_endpoints" // SSH exec-endpoint config
	AgentSiblings          Action = "agent.siblings"
	AgentModelsUpdate      Action = "agent.models.update"
	AgentRoutesView        Action = "agent.routes.view"
	AgentManagedBotCreate  Action = "agent.managed_bot.create"
	AgentIntegrationInvoke Action = "agent.integration.invoke"

	// Tenant axis.
	TenantCatalogView         Action = "tenant.catalog.view"           // read providers/models/capabilities catalog: user+
	TenantUserView            Action = "tenant.user.view"              // read tenant user directory (id/email/display_name): user+
	TenantAgentCreate         Action = "tenant.agent.create"           // create an agent: manager+
	TenantAgentList           Action = "tenant.agent.list"             // list agents visible to the caller: user+
	TenantAgentListAll        Action = "tenant.agent.list_all"         // list every agent in the tenant: admin
	TenantAgentMembersSelfAdd Action = "tenant.agent.members.self_add" // escape: tenant admin adds self to an agent they're not yet a member of: admin
	TenantAgentLifecycleAny   Action = "tenant.agent.lifecycle_any"    // stop/start/suspend an agent the caller isn't a member of: admin
	TenantAgentDeleteAny      Action = "tenant.agent.delete_any"       // delete an agent the caller isn't a member of: admin
	TenantAgentClone          Action = "tenant.agent.clone"            // clone an agent (produces a new agent): manager+ (paired with AgentClone member gate)
	TenantAgentTransferAny    Action = "tenant.agent.transfer_any"     // transfer an agent the caller doesn't own: admin
	TenantBridgeList          Action = "tenant.bridge.list"            // list bridges visible to the caller: user+
	TenantBridgeListAll       Action = "tenant.bridge.list_all"        // list every bridge in the tenant: admin
	TenantBridgeCreate        Action = "tenant.bridge.create"          // any bridge: manager+
	TenantBridgeUpdateAny     Action = "tenant.bridge.update_any"      // edit a bridge the caller doesn't own: admin
	TenantBridgeDeleteAny     Action = "tenant.bridge.delete_any"      // delete a bridge the caller doesn't own: admin
	TenantBridgeSystem        Action = "tenant.bridge.system"          // system (agent-less) bridge: admin
	TenantManagerBotConfig    Action = "tenant.manager_bot.config"     // configure the Telegram-managed-bots manager bot token: admin
	TenantUserManage          Action = "tenant.user.manage"
	TenantProviderView        Action = "tenant.provider.view" // list configured providers (no secrets) for model selection: manager+
	TenantProviderManage      Action = "tenant.provider.manage"
	TenantSettingsView        Action = "tenant.settings.view" // read system defaults (agent-create prefill): user+
	TenantSettingsUpdate      Action = "tenant.settings.update"
	TenantIdentityManage      Action = "tenant.identity.manage"     // link / list / unlink caller's own platform identities: user+
	TenantIdentityManageAll   Action = "tenant.identity.manage_all" // list / unlink any user's platform identities: admin
	TenantSelfProfileUpdate   Action = "tenant.self.profile.update" // update the caller's own profile: user+
	TenantSelfPasskeyManage   Action = "tenant.self.passkey.manage" // register / list / rename / delete the caller's own passkeys + set/remove own password: user+
	ResourceInventoryView     Action = "resource.inventory.view"    // list resources available through ownership or grants: user+
	ResourceView              Action = "resource.view"              // authenticated precondition; resource capability checked by AuthorizeResource
	ResourceBind              Action = "resource.bind"              // authenticated precondition; resource capability checked by AuthorizeResource
	ResourceManage            Action = "resource.manage"            // authenticated precondition; resource capability checked by AuthorizeResource
	TenantGroupView           Action = "tenant.group.view"          // list groups + their grants: admin
	TenantModelGrantManage    Action = "tenant.model_grant.manage"  // grant/revoke which (provider, model) a group may use: admin
	TenantUsageView           Action = "tenant.usage.view"          // read the LLM spend ledger rollups (billing/usage): admin
)

func GrantedTenantActions

func GrantedTenantActions(role auth.Role) []Action

GrantedTenantActions returns every tenant-axis Action that `role` satisfies, sorted lexicographically. The frontend consumes this via /api/v1/me to gate UI without duplicating the role ladder — `can(a)` becomes set membership, and adding a new Action surfaces in the UI purely through whatever can() call sites reference it.

Agent-axis actions are deliberately excluded: their requirement depends on per-resource membership, not tenant role, and surface in the agent payload's `your_access` field instead.

type Axis

type Axis int

Axis is the permission axis an action gates on. Agent access and tenant role are independent (see airlock/AGENTS.md "Permission Model"); authenticated actions establish identity before a domain-specific capability check.

const (
	AxisAgent         Axis = iota // requires a per-agent access level
	AxisTenant                    // requires a tenant role
	AxisIntegration               // agent admin or active codegen build for this agent
	AxisAuthenticated             // requires a registered user; another gate applies domain capability
)

type Grant

type Grant struct {
	GranteeID    uuid.UUID
	Capabilities []string
}

Grant is a resource_grants row reduced to what a capability check needs.

type Kind

type Kind int

Kind discriminates how a caller was authenticated. It exists to kill the old userID==uuid.Nil ambiguity, which conflated "anonymous human" with "no human at all".

const (
	// KindRegisteredUser — authenticated via a user JWT. UserID and
	// TenantRole are set.
	KindRegisteredUser Kind = iota
	// KindAnonymousUser — a human with no account (bridge public DM).
	// Resolves to AccessPublic; may do public-reachable actions but no
	// member/admin ones.
	KindAnonymousUser
	// KindTrigger — no human at all (cron/webhook). Cannot delegate as a
	// user; agent-axis actions and A2A initiation are denied.
	KindTrigger
	// KindCodegen is an active agent build using its narrow integration
	// credential. It has no tenant or ordinary agent standing.
	KindCodegen
)

type Principal

type Principal struct {
	Kind       Kind
	UserID     uuid.UUID // RegisteredUser only; uuid.Nil otherwise
	TenantRole auth.Role // RegisteredUser only

	// OnBehalfOfAgent records the agent whose code is acting (A2A /
	// system-agent delegation). Audit/logging only — authorization
	// evaluates the delegated principal as the originating user, no more.
	OnBehalfOfAgent uuid.UUID

	// BuildID and CodegenAgentID are set only for KindCodegen. The integration
	// policy verifies both against the active build row on every service call.
	BuildID        uuid.UUID
	CodegenAgentID uuid.UUID
}

Principal is the caller identity threaded through every gated call. Build it once at the surface boundary (handler / bridge / A2A) and pass it down; nothing below invents identity.

func AnonymousPrincipal

func AnonymousPrincipal() Principal

AnonymousPrincipal builds an anonymous-human principal (bridge public DM).

func CodegenPrincipal

func CodegenPrincipal(buildID, agentID uuid.UUID) Principal

CodegenPrincipal builds the narrow principal admitted only by integration policy actions while its build credential remains active.

func TriggerPrincipal

func TriggerPrincipal() Principal

TriggerPrincipal builds a non-human trigger principal (cron/webhook).

func UserPrincipal

func UserPrincipal(id uuid.UUID, role auth.Role) Principal

UserPrincipal builds a registered-user principal. A uuid.Nil id yields a principal that Authorize treats as unauthenticated (ErrUnauthorized).

func (Principal) EffectiveAgentAccess

func (p Principal) EffectiveAgentAccess(ctx context.Context, q *dbq.Queries, agentID uuid.UUID) agentsdk.Access

EffectiveAgentAccess resolves the principal's access on agentID off agent_grants. A grant may target the principal's own user id (per-user member) or a role-group in its grantee-set (e.g. the built-in `user` group = every registered user, "shared with everyone"); the effective access is the max role across all matching grants. Everyone with no matching grant (anonymous, trigger, non-member registered user) maps to AccessPublic. Surface-specific "is public allowed here" policy (e.g. the agent's allow_public_mcp flag) lives at the surface, not in this ladder.

func (Principal) EffectiveAgentAccessGranted

func (p Principal) EffectiveAgentAccessGranted(ctx context.Context, q *dbq.Queries, agentID uuid.UUID) (access agentsdk.Access, granted bool)

EffectiveAgentAccessGranted is EffectiveAgentAccess plus whether an actual grant matched. The access value alone can't distinguish "AccessPublic because an explicit `public` grant matched" from "AccessPublic because the caller is a non-member at the floor" — both read as AccessPublic. The A2A entitlement gate needs that distinction (an explicit All-Users `public` grant admits the caller; the bare floor does not), so it reads granted.

func (Principal) GranteeSet

func (p Principal) GranteeSet() []uuid.UUID

GranteeSet returns the principal ids a grant may target to reach p: p's own user id plus the built-in role-groups its tenant role belongs to. A grant to the `user` group reaches everyone; to `manager`, managers + admins. This is the open-source policy resolver — the single seam a higher tier swaps to also consult a stored-membership table. Returns nil for a non-registered principal (anonymous / trigger have no role standing).

func (Principal) HasResourceCapability

func (p Principal) HasResourceCapability(ownerPrincipalID uuid.UUID, grants []Grant, capability string) bool

HasResourceCapability reports whether p holds capability on a resource owned by ownerPrincipalID and carrying grants. True if p (or a role-group in its grantee-set) owns the resource — owners hold view/bind/manage implicitly — or holds a grant carrying capability.

func (Principal) IsAuthenticatedUser

func (p Principal) IsAuthenticatedUser() bool

IsAuthenticatedUser reports whether the principal is a registered user with a real UserID — the precondition for tenant-axis actions and for initiating A2A.

type Requirement

type Requirement struct {
	Axis   Axis
	Agent  agentsdk.Access // AxisAgent / AxisIntegration
	Tenant auth.Role       // AxisTenant
}

Requirement is the minimum access an action needs. Agent is meaningful for agent and integration axes; Tenant is meaningful for the tenant axis.

Jump to

Keyboard shortcuts

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