AnnouncementsIntroducing Arkoren

    Menu

    Writing Policies

    Group feature rules in policy structs and distinguish tenant roles, application permissions, and delegated scopes.

    What's on this page
    1. Organize feature decisions in policy structs with explicit dependencies.
    2. Check tenant-specific membership roles without hiding database failures as denials.
    3. Require token scopes alongside application permissions and resource access.
    On this page

    When several handlers need the same billing decision, keep that decision in a policy: an application-owned struct with rule methods and explicit dependencies. A policy is not an Arkoren registry or base type. Its methods follow the gate rule shape, so callers use the same checks from HTTP handlers, tools, or jobs.

    Feature Policies

    For a team billing feature, keep the authenticated actor separate from the team being managed. These application types distinguish global permissions from the role held in one team:

    type User struct {
    	ID          int
    	Permissions map[string]bool
    	Scopes      []string
    }
    
    type Team struct {
    	ID int `db:"id"`
    }
    
    type Membership struct {
    	Role string `db:"role"`
    }

    Authentication must store exactly User, not *User. Load Permissions from your trusted application data. Populate Scopes only when verified token credentials supply them; the token checks below use that field for a separate decision.

    Give the policy its configured database rather than reaching for a global. Import database from arkoren.dev/core/service/database:

    type BillingPolicy struct {
    	db *database.Database
    }
    
    policy := &BillingPolicy{db: db}

    Construct it during application setup and share it with the operations that need billing checks. Keep shared fields and captured inputs immutable during requests; the gate does not synchronize your policy’s state.

    Team Roles

    A role groups responsibilities. If a user can be an owner in one team and a viewer in another, look up the role for both the actor and the target team; a global User.Role cannot express that boundary.

    Assume memberships has a unique (team_id, user_id) pair and a role column. Import standard-library context and errors, and query from arkoren.dev/foundation/query. A billing rule allows owners and billing members:

    func (policy *BillingPolicy) CanManage(team Team) func(context.Context, User) (bool, error) {
    	return func(ctx context.Context, user User) (bool, error) {
    		member, err := policy.db.Find[Membership](ctx, query.Table("memberships", func(statement *query.Builder) {
    			statement.Select("role")
    			statement.Where("team_id", team.ID)
    			statement.Where("user_id", user.ID)
    		}))
    		if errors.Is(err, database.ErrNoRows) {
    			return false, nil
    		}
    		if err != nil {
    			return false, err
    		}
    
    		return member.Role == "owner" || member.Role == "billing", nil
    	}
    }

    No membership or an unrecognized role denies access. A lookup failure returns an error instead of disguising an unavailable database as a missing membership. Name the rule after the action, so changing the qualifying roles does not change what callers ask.

    With team returned by your authorized store lookup, apply the rule before changing billing details. Import gate from arkoren.dev/core/http/auth/gate:

    if err := gate.Authorize(ctx, policy.CanManage(team)); err != nil {
    	return err
    }

    The store lookup owns whether the caller may discover the team; this rule owns whether the caller may manage its billing. Neither decision authorizes a different team ID supplied later in the request. Keep writes tied to the same target, and use atomic write boundaries when membership changes and writes must be serialized.

    Explicit Permissions

    Use permissions, named abilities, when roles are too coarse. For example, allow selected staff to prepare billing exports without making them owners of every team. A missing map entry denies by default:

    func (policy *BillingPolicy) CanExport(_ context.Context, user User) (bool, error) {
    	return user.Permissions["billing:export"], nil
    }

    This is an action-level permission, not access to all billing records. Combine it with the target’s membership rule and scoped query when exporting one team’s data. You can resolve role grants and individual grants into Permissions during identity loading; Arkoren does not compute that union for you.

    Permissions loaded once remain a snapshot until you reload them. Decide whether that lifetime meets your revocation needs. Long-running or queued operations should reload the actor before work, and sensitive writes might need a transactional membership check rather than a request-start snapshot.

    Token Limits

    A scope limits a client’s delegated authority. It must restrict, not replace, the user’s application permissions and resource access. For a bearer-token API whose authenticator stores the User above, require the verified token scope as another rule of that same type. Here slices is the standard-library package:

    func hasBillingScope(_ context.Context, user User) (bool, error) {
    	return slices.Contains(user.Scopes, "billing:export"), nil
    }
    
    if err := gate.Authorize(ctx, hasBillingScope, policy.CanExport, policy.CanManage(team)); err != nil {
    	return err
    }

    This token-specific path denies when the scope is absent. Do not fill Scopes from a header or unverified claim, or copy the user’s full permissions into it: either action can expand what a restricted credential may do.

    If authentication instead stores oauth.Identity directly, the OAuth adapter provides a scope rule. Import oauth "arkoren.dev/driver/oauth/oauthauth"; with the bearer-authenticated router api, apply it as admission:

    api.With(gate.Require(oauth.HasScopes("billing:export"))).Post("/billing/exports", exportBilling)

    HasScopes requires every named scope, and an empty requirement allows any authenticated oauth.Identity. It neither authenticates nor checks local team membership. Do not combine it with User rules in the same gate call: the types differ. Map verified issuer-and-subject identity to your local actor before local resource decisions, following OIDC bearer verification. API keys also need their credential permissions intersected with the actor’s authority.

    Policy Tests

    Call rules directly to test decisions independently of HTTP. With policy backed by a test database, seed a team and a membership whose role is viewer. For that authenticated viewer, denial is the expected result:

    allowed, err := policy.CanManage(team)(t.Context(), viewer)
    if err != nil || allowed {
    	t.Fatalf("viewer billing access = %v, %v; want false, nil", allowed, err)
    }

    Also cover owner and billing roles, absent membership, a member of a different team, unknown roles, and database failure. Test missing scopes separately from missing application permissions, then test protected routes for allowed, forbidden, and anonymous callers. Direct rule tests cannot prove that a handler actually calls the rule.

    For all functions and options, see the gate API reference.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive