speakeasy-api/gram/.agents/skills/gram-rbac/SKILL.md
gram-rbac
Concepts, external interfaces, and conventions for Gram's role-based access control (RBAC) subsystem — scopes, grants, principals, system roles, and the `authz.Engine.Require` enforcement path used inside handlers. Activate whenever the task involves authorization (adding or modifying a scope or resource type, declaring a new role or grant, gating a handler, changing scope inheritance, exposing RBAC state through the dashboard).
- Source repository stars
- 266
- Declared platforms
- 0
- Static risk flags
- 1
- Last source update
- 2026-08-25
- Source checked
- 2026-08-25
Decision brief
What it does: where it fits
Scope. A named permission that authorizes an operation on a particular kind of resource.
Not for
- Tasks that require unconfirmed production actions or broad system permissions.
- Environments where the pinned source and install steps cannot be inspected.
Compatibility matrix
Platform support, with evidence labels
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
Inspect first. Install second.
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/speakeasy-api/gram --skill ".agents/skills/gram-rbac"Inspect the Agent Skill "gram-rbac" from https://github.com/speakeasy-api/gram/blob/8af0601cf530721aaa686c2718d76e7990226d33/.agents/skills/gram-rbac/SKILL.md at commit 8af0601cf530721aaa686c2718d76e7990226d33. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
What the source asks the agent to do
- 01
How to gate a handler with an existing scope
1. Inject authz.Engine into the service struct (if it isn't already) and keep it on s.authz. 2. At the top of the handler — before any database work — call s.authz.Require(ctx, authz.Check{Scope: authz.Scope, ResourceKind: "", ResourceID: authCtx.ProjectID.String(), Dimensions:…
Inject authz.Engine into the service struct (if it isn't already) and keep it on s.authz.At the top of the handler — before any database work — call s.authz.Require(ctx, authz.Check{Scope: authz.Scope, ResourceKind: "", ResourceID: authCtx.ProjectID.String(), Dimensions: nil}) and return the error as-is. Th…Choose the narrowest scope for the operation: :read for GET/list, :write for mutations, :connect for runtime usage. Scope expansions mean write callers are still permitted to read. - 02
How to add a new scope to an existing resource type
Use this when the resource type is already represented (e.g. adding a new verb on mcp).
Add the Scope constant in server/internal/authz/scopes.go.Add the new scope to scopeExpansions in the same file. Usually: the new scope is the upper or lower end of an existing read/write/connect triple.Extend SystemRoleGrants in server/internal/authz/grants.go: admin always receives the new scope. Member receives it if and only if end users should have it by default (read and connect, yes; write, no). - 03
How to add a new resource type
Use this when introducing a resource type that doesn't exist yet (e.g. the first foo: scopes).
Follow every step under "How to add a new scope to an existing resource type" for each scope on the new type.Additionally, add the new resource type string to ScopeModel.resourcetype in server/design/access/design.go.Additionally, add the new resource type to the ResourceType union in client/dashboard/src/pages/access/types.ts. - 04
How to change system role defaults
Use this when adjusting what admin or member gets out of the box. Prefer additive changes — removing a grant from a shipped role is an observable permissions change for existing users.
Edit SystemRoleGrants in server/internal/authz/grants.go.Update expectedFullAccessScopes in server/internal/access/listusergrantstest.go if the admin set changed.Update the "Dashboard Grant Reference" table in docs/rbac.md if the default grant change affects what a built-in role can do in the dashboard. - 05
How to narrow an MCP check by tool or disposition
Use this when a single handler should authorize per-tool — e.g. private MCP tool calls where a grant might allow only readonly tools. The canonical call site is server/internal/mcp/rpctoolscall.go.
Build dimensions with the typed struct in authz/checks.go rather than a raw map: authz.MCPToolCallDimensions{Tool: params.Name, Disposition: disposition}. Zero-value fields are dropped automatically.For tool dispositions, derive the value from types.ToolAnnotations via conv.DispositionFromAnnotations(annotations) — priority order is readonly destructive idempotent openworld; missing or nil annotations yield an empt…Build the check with the matching helper: authz.MCPToolCallCheck(toolsetID, dims). For new dimension shapes, add a fresh helper to authz/checks.go rather than scattering raw Check{Dimensions: …} literals across services.
Permission review
Static risk signals and limitations
Reads files
The documentation asks the agent to read local files, directories, or repositories.
`server/internal/access/` implements the Goa `access` service on top of `authz`. Every handler calls `s.authz.Require(...)` with the appropriate scope before doing work. The package also owns `queries.sql` and the generated `server/internalReads files
The documentation asks the agent to read local files, directories, or repositories.
| `server/internal/access/queries.sql` | SQLc queries for principals, grants, roles, and members. Regenerates `server/internal/access/repo/` via `mise run gen:sqlc-server`. |Evidence record
Why each signal appears
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 94/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 266 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Provenance and original SKILL.md
- Repository
- speakeasy-api/gram
- Skill path
- .agents/skills/gram-rbac/SKILL.md
- Commit
- 8af0601cf530721aaa686c2718d76e7990226d33
- License
- AGPL-3.0
- Collected
- 2026-08-25
- Default branch
- main
View the original SKILL.md
Gram's RBAC is a scope-and-selector model. The server ships with a fixed set of scopes grouped into system roles (admin, member). A grant binds a scope to a selector (a Kubernetes-style map[string]string of resource_kind, resource_id, plus optional narrowing dimensions like tool or disposition) for a given principal (user or custom role). Handlers enforce scopes by calling authz.Engine.Require(ctx, authz.Check{...}); the dashboard renders the same scope vocabulary through a matching TypeScript union that is hand-maintained in lockstep with the server.
Concepts and terminology
Scope. A named permission that authorizes an operation on a particular kind of resource.
Resource type. The kind of resource a scope protects — currently org, project, or mcp. Every scope has exactly one resource type.
Scope expansion. Higher-privilege scopes satisfy lower-privilege ones. In the read/write/connect family the privilege order is write > read > connect: mcp:write satisfies a mcp:read check, and either mcp:read or mcp:write satisfies a mcp:connect check (connect is the broadest, easiest-to-satisfy gate). The mapping lives in scopeExpansions in authz/scopes.go — key = required scope, value = higher-privilege scopes that also satisfy it.
Selector. A map[string]string of constraints attached to a grant or check. Always carries resource_kind and resource_id (both required); MCP scopes additionally allow tool and disposition. Wildcards are explicit values — {"resource_kind":"*","resource_id":"*"}, never empty {}. Defined in server/internal/authz/selector.go.
Selector matching. A grant selector satisfies a check selector when, for every key the grant constrains, either the values are equal or the grant value is "*". Keys present on the grant but absent from the check are skipped — this is what lets a disposition-scoped grant ({"disposition":"read_only"}) still satisfy a connection-level check that doesn't constrain disposition.
Grant. A tuple of {Scope, Selector} held by a principal. The API-visible forms are RoleGrant (carrying Selectors []Selector) and ListRoleGrant (which also carries the transitively-implied sub_scopes). Use authz.NewGrant(scope, resourceID) to construct one — it derives the selector's resource_kind from the scope family.
Principal. Who holds a grant — a urn.Principal with a type (user, role, service account) and an id.
Dimensions. Optional narrowing keys on a Check beyond resource_id. Today: tool and disposition for MCP scopes (see server/internal/authz/checks.go and MCPToolCallCheck). Allowed keys per scope family are enforced by ValidateSelector; new dimensions must be added to allowedSelectorKeys in selector.go.
Disposition. A snake_case bucket derived from MCP tool annotation hints — read_only, destructive, idempotent, open_world. Constants live in authz/selector.go; conv.DispositionFromAnnotations(annotations) is the canonical conversion from *types.ToolAnnotations.
System role. A built-in role shipped with the server. Gram defines two: admin (every scope) and member (the read-and-connect subset). Constants authz.SystemRoleAdmin and authz.SystemRoleMember.
Enforcement. Inside a handler, authorization is an explicit one-line check: the handler names the scope (and resource, if project-scoped) it needs, and the RBAC engine either allows the call or returns a forbidden error.
Auth context invariant. Organization-scoped handlers can assume ActiveOrganizationID is populated. During login, session authentication can briefly produce an org-less context; Engine.PrepareContext handles that middleware boundary by installing zero grants instead of trying organization-scoped principal resolution. Endpoints already exposed during login keep their explicit org-less behavior: for example, access.ListGrants returns no grants, productfeatures.GetProductFeatures returns unauthorized, and auth.Info continues the login flow. Do not spread defensive empty-org checks into other handlers.
Server
RBAC is split across two Go packages: server/internal/authz/ holds the enforcement primitives, and server/internal/access/ implements the Goa management service that exposes them over HTTP. When adding new authorization primitives (scopes, checks, enforcement logic) edit authz; when adding or changing the management API (role/member endpoints) edit access.
authz package — the enforcer
Scope vocabulary, grant types, and enforcement logic are defined here. authz's imports are deliberately minimal (DB, logger, cache, WorkOS, urn) so any package that gates on RBAC can depend on it without import cycles — never add an import to authz from a package that transitively depends on authz, since the split exists specifically to prevent the cycles that motivated it.
Scope declarations. Scope type and every Scope* constant live in server/internal/authz/scopes.go. Constants follow the Scope<Name> pattern; string values follow <resource>:<verb> (e.g. mcp:read, org:admin). ScopeRoot is reserved for service-internal superadmin overrides. The file also holds the scopeExpansions map and computes the inverse scopeSubScopes in init(). CalculateSubScopes(scope) exposes the inverse to callers.
System role grants. SystemRoleGrants in server/internal/authz/grants.go — admin and member defaults. Adding a scope usually means adding it to admin, and optionally to member if end users should get it by default. SeedSystemRoleGrants upserts the full set; SyncGrants upserts grants for a single role slug.
authz.Engine. The central enforcer. Methods: PrepareContext, Require(ctx, checks...), RequireAny(ctx, checks...), Filter(ctx, scope, ids), ShouldEnforce, InvalidateRoleCache, InvalidateAllRoleCaches, GetScopeOverrides. Constructed in server/cmd/gram/start.go via authz.NewEngine(logger, db, chDB, challengeLogging, membership, opts...) and injected into every service that gates on RBAC. RBAC is always enforced for eligible authenticated requests; the MembershipFetcher is the WorkOS client used for role-slug lookups.
Organization provisioning. authz.Provisioner seeds both built-in roles and their grants for every organization created through Gram. ProvisionOrganizationAdmin performs that seed and assigns the first user to SystemRoleAdmin in one transaction. The identity leaf package never imports authz. WorkOS organization event reconciliation calls SeedSystemRoleGrantsTx for organizations discovered through WorkOS.
authz.Check. {Scope, ResourceKind, ResourceID, Dimensions} — the thing a handler asks Require to enforce. For the common single-resource case, leave ResourceKind: "" (auto-derived from the scope family) and Dimensions: nil; exhaustruct requires every field at every call site. ResourceID is typically authCtx.ProjectID.String() for project-scoped scopes. Defined in server/internal/authz/access.go.
authz.Filter for list endpoints. When a handler lists resources the caller might only partially own, s.authz.Filter(ctx, scope, candidateIDs) ([]string, error) returns the subset of IDs the caller holds the scope for. The standard pattern is: gather candidate IDs from the repo, call Filter, then rebuild the response from the allowed IDs. Prefer this over a post-hoc per-item Require loop. Canonical call sites: server/internal/projects/impl.go (projects list) and server/internal/toolsets/impl.go (toolsets list).
Auth context accessor. contextvalues.GetAuthContext(ctx) returns the current *AuthContext. RBAC-relevant fields: ActiveOrganizationID, ProjectID, UserID, Email, IsAdmin, APIKeyID, SessionID. AccountType is billing metadata and does not control RBAC enforcement.
Scope overrides. A local-dev/superadmin header can inject a restricted grant set for the request, parsed in override.go and surfaced via Engine.GetScopeOverrides. access.ListGrants returns the override set verbatim when active so the dashboard reflects what the engine will enforce.
Error model. errors.go defines sentinel errors (ErrDenied, ErrMissingGrants, ErrNoChecks, ErrInvalidCheck) and typed errors (DeniedError, InvalidCheckError). The engine maps these to oops codes — ErrDenied → oops.CodeForbidden, everything else → oops.CodeUnexpected with a logged message.
Grant loading. LoadGrants(ctx, db, orgID, principals) reads the principal URN set and returns the flattened []Grant. Called by both Engine.PrepareContext (middleware path) and access.ListGrants (user-facing). Each row's selectors JSONB is parsed via SelectorFromRow.
Sync semantics. SyncGrants distinguishes nil from empty: RoleGrant{Selectors: nil} writes a single wildcard row; RoleGrant{Selectors: []Selector{}} writes nothing (no access). Each non-nil selector is validated by ValidateSelector before insert.
access package — the management API
server/internal/access/ implements the Goa access service on top of authz. Every handler calls s.authz.Require(...) with the appropriate scope before doing work. The package also owns queries.sql and the generated server/internal/access/repo/ SQLc package that both access and authz use to read and write grant rows.
Scope metadata. ListScopes in server/internal/access/impl.go returns one {Slug, Description, ResourceType} entry per scope; this is what the dashboard consumes to render the scope picker.
Full-access grant list. ListGrants returns a hard-coded full-access scope list when enforcement is intentionally skipped, such as sessionless or API-key requests. That inline list in server/internal/access/impl.go must grow whenever a new scope is added. The parallel test expectation is expectedFullAccessScopes in server/internal/access/listusergrants_test.go.
System role gating. isSystemRole(slug) in impl.go checks against authz.SystemRoleAdmin and authz.SystemRoleMember. System roles cannot be renamed, deleted, or have their grant set edited; only member assignment is allowed.
Non-generated files
| File | Purpose |
|---|---|
server/design/access/design.go | Goa design for the access service. Regenerates server/gen/access/ and server/gen/http/access/ via mise run gen:goa-server. |
server/internal/authz/access.go | The Check type and its expansion logic. |
server/internal/authz/checks.go | Pre-built Check builders for multi-dimensional checks (e.g. MCPToolCallCheck, MCPToolCallDimensions). |
server/internal/authz/context.go | Request-context helpers for grants (GrantsToContext, GrantsFromContext). |
server/internal/authz/engine.go | The Engine type — central RBAC enforcer, role-slug caching, and override resolution. |
server/internal/authz/errors.go | Package sentinel errors and typed errors. |
server/internal/authz/grants.go | Grant/RoleGrant/ScopedGrant types, SystemRoleGrants, SyncGrants, SeedSystemRoleGrants, GrantsForRole, GrantsToScopedGrants. |
server/internal/authz/load.go | Principal grant loading from the database. |
server/internal/authz/override.go | Scope override plumbing (header parsing, override-to-grants conversion). |
server/internal/authz/provisioner.go | New-organization provisioning for built-in role grants and the initial Admin assignment. |
server/internal/authz/scopes.go | Scope type, constants, and expansion rules. |
server/internal/authz/selector.go | Selector type, matching rules, NewSelector/NewGrant helpers, ValidateSelector, ResourceKindForScope, disposition vocabulary, SelectorFromRow. |
server/internal/authztest/helpers.go | Test helpers other packages reuse for RBAC setup, including WithExactGrants. |
server/internal/access/impl.go | Implementation of the /rpc/access.* Goa service. |
server/internal/access/queries.sql | SQLc queries for principals, grants, roles, and members. Regenerates server/internal/access/repo/ via mise run gen:sqlc-server. |
Generated files
| Path | Generator |
|---|---|
server/gen/access/, server/gen/http/access/ | mise run gen:goa-server from server/design/access/design.go. |
server/internal/access/repo/ | mise run gen:sqlc-server from server/internal/access/queries.sql (via the access stanza in server/database/sqlc.yaml). |
Server-client contract
Scope and resource-type changes on the server ripple into the generated SDK types (via gen:sdk) and a few hand-maintained docs and tests — adding a scope is not purely a server-package change.
HTTP routes (design: server/design/access/design.go):
/rpc/access.listScopes— every scope the server knows about, withresource_typeand description./rpc/access.listRoles,getRole,createRole,updateRole,deleteRole— custom role CRUD./rpc/access.listMembers,updateMemberRole— org membership and role assignment./rpc/access.listUserGrants— the caller's effective grants.
Three-place enum lockstep. server/design/access/design.go repeats the scope slug enum in three places — RoleGrantModel.scope, ListRoleGrantModel.scope, and its sub_scopes element — plus ScopeModel.slug for the listing endpoint. All three must stay synchronized with authz/scopes.go, and ScopeModel.resource_type must contain every resource type in use. Adding a new resource type also means adding it to SelectorModel.resource_kind's enum (project, mcp, org, *) — the model that backs RoleGrant.selectors and ListRoleGrant.selectors.
Generated SDK types. client/dashboard/src/sdk/src/models/components/scopedefinition.ts, rolegrant.ts, listrolegrant.ts, selector.ts, etc. Regenerated by mise run gen:sdk after every design change.
UI-owned access-page types. client/dashboard/src/pages/access/types.ts owns the dashboard-only abstractions layered over the generated SDK: the ResourceType string-literal union, AnnotationHint, CustomTab, ActivePanel, PolicyEffect, ScopeRule, a UI RoleGrant interface (selectors: Selector[] | null, distinct from the SDK's RoleGrant), and toRoleSlug. It also owns the ANNOTATION_TO_DISPOSITION / DISPOSITION_TO_ANNOTATION maps that mirror the disposition vocabulary in authz/selector.go — keep these in lockstep when adding or renaming dispositions. It imports Scope / Selector / SelectorDisposition from the SDK for local use in those definitions.
Client
The dashboard pages under client/dashboard/src/pages/access/ render membership and role management on top of the generated SDK and the listScopes response. RBAC-aware UI across the rest of the dashboard gates itself through a shared hook and component.
Conventions
useRBAC hook. client/dashboard/src/hooks/useRBAC.ts wraps the generated useGrants React Query hook and exposes hasScope(scope, resourceId?), hasAllScopes(scopes, resourceId?), hasAnyScope(scopes, resourceId?), plus isLoading, grants, and error. Returns false from the has* checks while grants are loading. The module also exports selectorMatches(grant, check) and resourceKindForScope(scope) — direct mirrors of the server-side helpers in authz/selector.go — for code that needs parity with backend matching outside the standard hasScope flow.
RequireScope component. client/dashboard/src/components/require-scope.tsx is the primary rendering gate. Props: scope: Scope | Scope[], all?: boolean (AND vs OR when multiple scopes), resourceId?: string, level: "page" | "section" | "component", children, and level-specific extras (fallback for page/section, reason/className for component).
level="page"— renders a full Unauthorized fallback page when the scope is missing.level="section"— hides the children entirely.level="component"— renders disabled with a tooltip explaining why (good for buttons and inputs).
Scope vocabulary import. useRBAC, RequireScope, and the access pages import the Scope union directly from the generated SDK (@gram/client/models/components/rolegrant.js). That union is regenerated from the server scope enum by gen:sdk, so keeping server/design/access/design.go in lockstep with authz/scopes.go is what makes the client gates type-check.
Dashboard grant reference. docs/rbac.md contains the "Dashboard Grant Reference" table that maps dashboard pages and actions to the grants required to use them. Whenever adding a new scope, changing a system-role grant default, adding a new dashboard RBAC gate, or changing the grant required by a dashboard feature, update that table in the same change. The table is the first place to answer questions like "what grant is required to create a project?"
Non-generated files
| File | Purpose |
|---|---|
client/dashboard/src/components/require-scope.tsx | RequireScope gating component — page, section, and component-level rendering gates. |
client/dashboard/src/hooks/useRBAC.ts | useRBAC hook — scope checks and raw grants for the dashboard. |
client/dashboard/src/pages/access/Access.tsx | Top-level access page shell. |
client/dashboard/src/pages/access/ChangeRoleDialog.tsx, CreateRoleDialog.tsx, DeleteRoleDialog.tsx | Role and member-role mutation dialogs. |
client/dashboard/src/pages/access/MembersTab.tsx, RolesTab.tsx | The two tabs of the access page. |
client/dashboard/src/pages/access/ScopePickerPopover.tsx | Scope selection UI. |
client/dashboard/src/pages/access/types.ts | UI-only access-page types (ResourceType, ScopeRule, UI RoleGrant) and disposition maps. (See "Server-client contract".) |
Jobs to be done
How to gate a handler with an existing scope
- Inject
*authz.Engineinto the service struct (if it isn't already) and keep it ons.authz. - At the top of the handler — before any database work — call
s.authz.Require(ctx, authz.Check{Scope: authz.Scope<Name>, ResourceKind: "", ResourceID: authCtx.ProjectID.String(), Dimensions: nil})and return the error as-is. The exhaustruct linter requires everyCheckfield — leaveResourceKindempty to auto-derive from the scope family andDimensionsnil unless you're narrowing by tool/disposition. - Choose the narrowest scope for the operation:
*:readfor GET/list,*:writefor mutations,*:connectfor runtime usage. Scope expansions mean write callers are still permitted to read. - Use
RequireAnyinstead ofRequirewhen a single handler legitimately satisfies multiple equivalent scopes. - In the handler's test, add one case that builds the context without the scope and asserts an
oops.CodeForbiddenresponse, and one case that builds the context with the scope viaauthztest.WithExactGrants(t, ctx, authz.NewGrant(authz.Scope<Name>, resourceID)). Construct grants withauthz.NewGrant(orauthz.NewGrantWithSelectorfor non-trivial selectors) — never setGrant.Selectorby hand.
How to add a new scope to an existing resource type
Use this when the resource type is already represented (e.g. adding a new verb on mcp).
- Add the
Scope<Name>constant inserver/internal/authz/scopes.go. - Add the new scope to
scopeExpansionsin the same file. Usually: the new scope is the upper or lower end of an existing read/write/connect triple. - Extend
SystemRoleGrantsinserver/internal/authz/grants.go: admin always receives the new scope. Member receives it if and only if end users should have it by default (read and connect, yes; write, no). - Add a
{Slug, Description, ResourceType}entry toListScopesinserver/internal/access/impl.go. - Extend the hard-coded full-access scope list in
ListGrants(sameimpl.go) so callers without grants loaded still see the complete catalogue. - Update the three enums in
server/design/access/design.gothat have to stay in lockstep. - No dashboard edit is needed for the
Scopeunion — it is regenerated in the SDK (@gram/client/models/components/rolegrant.js) bygen:sdkin step 10. Confirm the new slug appears there after regeneration. - Bump
expectedFullAccessScopesinserver/internal/access/listusergrants_test.goand therequire.Len(t, result.Scopes, N)assertion inserver/internal/access/listscopes_test.go. - Update the "Dashboard Grant Reference" table in docs/rbac.md if the new scope affects any dashboard route, action, or user-facing grant question.
- Run
mise run gen:goa-server, thenmise run gen:sdk. - Run
mise run lint:serverandmise run test:server.
How to add a new resource type
Use this when introducing a resource type that doesn't exist yet (e.g. the first foo:* scopes).
- Follow every step under "How to add a new scope to an existing resource type" for each scope on the new type.
- Additionally, add the new resource type string to
ScopeModel.resource_typeinserver/design/access/design.go. - Additionally, add the new resource type to the
ResourceTypeunion inclient/dashboard/src/pages/access/types.ts.
How to change system role defaults
Use this when adjusting what admin or member gets out of the box. Prefer additive changes — removing a grant from a shipped role is an observable permissions change for existing users.
- Edit
SystemRoleGrantsinserver/internal/authz/grants.go. - Update
expectedFullAccessScopesinserver/internal/access/listusergrants_test.goif the admin set changed. - Update the "Dashboard Grant Reference" table in docs/rbac.md if the default grant change affects what a built-in role can do in the dashboard.
- Consider whether existing orgs' grant tables need a migration to reflect the new defaults; new orgs pick up defaults automatically during organization provisioning.
- Run
mise run lint:serverandmise run test:server.
How to narrow an MCP check by tool or disposition
Use this when a single handler should authorize per-tool — e.g. private MCP tool calls where a grant might allow only read_only tools. The canonical call site is server/internal/mcp/rpc_tools_call.go.
- Build dimensions with the typed struct in
authz/checks.gorather than a raw map:authz.MCPToolCallDimensions{Tool: params.Name, Disposition: disposition}. Zero-value fields are dropped automatically. - For tool dispositions, derive the value from
*types.ToolAnnotationsviaconv.DispositionFromAnnotations(annotations)— priority order is read_only > destructive > idempotent > open_world; missing or nil annotations yield an empty string (which gets dropped). - Build the check with the matching helper:
authz.MCPToolCallCheck(toolsetID, dims). For new dimension shapes, add a fresh helper toauthz/checks.gorather than scattering rawCheck{Dimensions: …}literals across services. - If you're introducing a brand-new dimension key, allowlist it in
allowedSelectorKeysinauthz/selector.go, otherwiseValidateSelectorwill reject any role grant that uses it. New disposition values must also be added tovalidDispositionsand to thedispositionenum onSelectorModelinserver/design/access/design.go. - Selector-matching skips dimensions that the grant doesn't constrain — a grant of
mcp:connectwith notoolkey still satisfies a check that names a specific tool. This is intentional; it lets less-narrow grants cover more checks.
How to filter a list handler to the caller's accessible resources
Use this whenever a list* handler would otherwise return resources the caller has no grant for. projects.List and toolsets.List are the canonical examples.
- Query the repo for the full candidate set the org/project contains.
- Collect the candidate IDs into
[]string. - Call
allowedIDs, err := s.authz.Filter(ctx, authz.Scope<Name>, candidateIDs). Return the error as-is. - Build a set from
allowedIDsand rebuild the response by walking the original rows, keeping only the ones whose ID is in the set. Preserves repo ordering without a second query. - Do not fall back to a per-item
Requireloop —Filterexists specifically to avoid N authorization round-trips.
How to gate dashboard UI with RBAC
Dashboard code should never hand-roll scope checks — use the shared primitives so a change to useRBAC or <RequireScope> flows through the whole app.
-
Rendering gates — use
<RequireScope>. Pick the level that matches what you want the un-entitled user to see:level="page"around a full route component renders an Unauthorized fallback page.level="section"around a block hides it entirely.level="component"around a button or input renders disabled with a tooltip reason.
<RequireScope scope="org:admin" level="component" reason="Admin only"> <Button onClick={() => setDialogOpen(true)}>New API key</Button> </RequireScope> -
Multi-scope gates. Pass an array and set
allto switch between OR (default) and AND logic:<RequireScope scope={["org:read", "org:admin"]} level="page">. -
Resource-specific gates. Pass
resourceIdwhen the scope only applies to a specific resource:<RequireScope scope="mcp:write" resourceId={toolsetId} level="component">. -
Imperative checks — use
useRBAC. When you need the scope result as a value (to compute a class name, skip an effect, pick a label), pull from the hook instead of wrapping markup:const { hasScope, isLoading } = useRBAC(); const canEdit = hasScope("mcp:write", toolsetId); -
The
Scopestring you pass must match the server. ImportScopedirectly from the generated SDK (@gram/client/models/components/rolegrant.js). If TypeScript complains about an unknown scope, you're missing the union update from the server scope add (see "How to add a new scope to an existing resource type"). -
Update the dashboard grant reference. Any new or changed dashboard gate must update the "Dashboard Grant Reference" table in docs/rbac.md, including page-level access, component/action-level access, resource selector target, and any notable server-side check that differs from the visible UI gate.
How to inspect the caller's grants
- In the dashboard:
const { grants } = useRBAC();returns the rawRoleGrant[]. PreferhasScopefor gating; reach forgrantsonly when you need to render them (the access page itself, diagnostics, dev overlays). - In Go handlers:
authz.GrantsFromContext(ctx)returns the grants on the request context after the engine'sPrepareContextmiddleware has run. - Over the API:
GET /rpc/access.listUserGrantsreturns the caller's effective grants.
Role hierarchy at a glance
admin— every scope. Write implies read viascopeExpansions, so admins can exercise every read operation transitively.member— the read-and-connect subset.- Resource scoping — a grant's selector either names a specific resource (
{"resource_kind":"project","resource_id":"proj_123"}) or wildcards it ({"resource_kind":"*","resource_id":"*"}viaauthz.WildcardResource). A grant value of*matches anything for that selector key. root(authz.ScopeRoot) — held only by service-internal overrides; satisfies every check.
Relevant mise tasks
| Task | Purpose |
|---|---|
mise run gen:goa-server | Regenerate server/gen/access/** after editing server/design/access/design.go. |
mise run gen:sdk | Regenerate the SDK and OpenAPI so dashboard/CLI consumers see the new scope vocabulary. |
mise run gen:sqlc-server | Regenerate server/internal/access/repo/ when queries.sql changes. Requires mise run infra:start (sqlc connects to the local Postgres to type-check queries). |
mise run lint:server | Catches exhaustruct violations in the scope/grant structs. |
mise run test:server | Runs the scope-count assertions and RBAC tests. Filter with ./internal/authz/... ./internal/access/... when iterating. |
Maintaining this skill
This file documents conventions that evolve over time. Adding a new scope, resource type, or tweaking system-role defaults is already covered by "Jobs to be done" — those don't require skill edits. Structural changes do. Update this skill in the same commit when you make any of the following kinds of changes:
- Changing the
<resource>:<verb>scope naming convention. - Adding or removing a system role beyond
adminandmember. - Replacing
authz.Engineas the central enforcer, or changing its method set (Require,RequireAny,Filter,PrepareContext,ShouldEnforce, etc.) or constructor signature. - Moving authorization primitives back into
accessor into a new package — theauthz/accesssplit is deliberate and load-bearing for import-cycle reasons. - Changing the
Checkstruct shape (currently{Scope, ResourceKind, ResourceID, Dimensions}) or theSelectortype's matching rules. - Adding a new selector dimension key (currently
tool,dispositionfor MCP) — including changes toallowedSelectorKeysorvalidDispositionsinauthz/selector.go, or to the matchingSelectorModelenums in the design file. - Changing scope-expansion semantics (e.g. how
scopeSubScopesis computed fromscopeExpansions, or introducing transitive expansion). The expansion algorithm currently emits one entry per scope level (relying on selector matching to handle wildcards) — switching back to per-scope×per-resource enumeration would change the perf profile and is worth re-documenting. - Changing where the full-access scope catalogue lives (currently inline in
access.ListGrantsand mirrored byexpectedFullAccessScopesin tests), or whereListScopesis populated. - Moving the hand-maintained client scope vocabulary out of
client/dashboard/src/pages/access/types.ts, or changing the three-place-enum-lockstep count in the design file. Same applies if theANNOTATION_TO_DISPOSITION/DISPOSITION_TO_ANNOTATIONmaps move out of that file. - Changing the auth context invariant — e.g. if
ActiveOrganizationIDbecomes optional, or a new invariant field is added. - Changing or replacing the dashboard's RBAC primitives —
useRBACreturn shape (includingselectorMatches/resourceKindForScopehelpers),<RequireScope>levels/props, or the SDK hook the dashboard reads grants from. - Renaming or replacing the canonical Go grant constructor (
authz.NewGrant,authz.NewGrantWithSelector,authz.NewSelector) — every test in the codebase is wired through these. - Adding a new RBAC-relevant mise task that belongs on the cheat sheet.
- Changing the test-helper surface in
authztest(e.g. renamingWithExactGrantsor adding a new canonical helper tests should use).
Cross-references
gram-management-api— theaccessservice itself, and every service that gates handlers withauthz.Require, follows that skill's flow.gram-audit-logging— role and member mutations emit audit events viaserver/internal/audit/access.go; subjects areaccess_roleandaccess_member.golang— error handling throughoops, the no-defensive-checks rule forActiveOrganizationID, thesetup_test.go/ black-box test conventions used by RBAC tests.frontend— everything underclient/dashboard/src/pages/access/(component structure,cn()/design-system styling, React Query usage).postgresql— theprincipal_grants(withselectors JSONB NOT NULL),roles, and related tables backing theaccess/repoSQLc package.mise-tasks— when modifying the.mise-tasks/gen/*.shscripts referenced above.
Frequently asked questions
What to verify before installation and use
What does the gram-rbac source document cover?
Scope. A named permission that authorizes an operation on a particular kind of resource.
How do I install gram-rbac?
The source record exposes this install command: npx skills add https://github.com/speakeasy-api/gram --skill ".agents/skills/gram-rbac". Inspect the command and pinned source before running it.
Which permission-related actions were detected?
Static rules flagged read-files in the source; the page lists the matching lines and excerpts.
Alternatives
Compare before choosing
coreyhaines31/marketingskills
ab-testing
When the user wants to plan, design, or implement an A/B test or experiment, or build a growth experimentation program. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "should I test this," "which version is better," "test two versions," "statistical significance," "how long should I run this test," "growth experiments," "experiment velocity," "experiment backlog," "ICE score," "experimentation program
garrytan/gbrain
bulk-ingestion
End-to-end discipline for turning any large data source (audio libraries, email takeouts, document corpora, chat exports, API dumps) into brain pages at scale. The lifecycle spine: SCHEMA → ACCESS → TRIAL → EVALUATE → IMPROVE → CODIFY → TEST → SKILLIFY → BULK → MONITOR. State is tracked in a durable JSON manifest (see MANIFEST-PATTERN.md) so any crash, session boundary, or subagent fan-out resumes from ground truth instead of memory.
alirezarezvani/claude-skills
app-store-optimization
App Store Optimization (ASO) toolkit for researching keywords, analyzing competitor rankings, generating metadata suggestions, and improving app visibility on Apple App Store and Google Play Store. Use when the user asks about ASO, app store rankings, app metadata, app titles and descriptions, app store listings, app visibility, or mobile app marketing on iOS or Android. Supports keyword research and scoring, competitor keyword analysis, metadata optimization, A/B test planning, launch checklist
dotnet/skills
migrate-vstest-to-mtp
Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP). Use when user asks to "migrate to MTP", "switch from VSTest", "enable Microsoft.Testing.Platform", "use MTP runner", set OutputType=Exe only for test projects in Directory.Build.props, or mentions EnableMSTestRunner, EnableNUnitRunner, or UseMicrosoftTestingPlatformRunner. USE FOR: MTP behavioral differences vs VSTest (exit code 8, zero tests discovered, --ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE); centralizing