Skip to content

feat: add ui for role and role assignment management#1970

Open
adaam2 wants to merge 57 commits intomainfrom
feat/rbac
Open

feat: add ui for role and role assignment management#1970
adaam2 wants to merge 57 commits intomainfrom
feat/rbac

Conversation

@adaam2
Copy link
Copy Markdown
Member

@adaam2 adaam2 commented Mar 24, 2026

Summary

  • Role and member role assignment management backed by WorkOS Authorization APIs
  • UI for managing roles: roles table, create/edit role dialog with permission picker, member assignment with role transition badges
  • Fine-grained scope/resource grants stored in Gram's principal_grants table
  • Feature-flagged behind PostHog gram-rbac flag (org-level sidebar nav + route guard)
  • Moved support/docs/changelog links from header to sidebar "Get help" group

Key Architecture

  • Hybrid RBAC model: WorkOS owns role definitions + user↔role assignments; Gram's principal_grants table owns fine-grained scope/resource grants
  • WorkOS EnvironmentRoles (admin, member) are treated as system roles — metadata updates are skipped
  • WorkOS OrganizationRoles use org- slug prefix, unique per organization
  • Server resolves between WorkOS role IDs and slugs transparently
  • No local roles/assignments tables — WorkOS is the sole authority for role CRUD and user↔role mappings

Changes

Server

  • Goa API design for access service: roles CRUD, member assignment, scopes listing, and principal grants
  • WorkOS API integration for role and member management
  • Hardened error handling: 409 conflict on duplicate role creation, system role update safety
  • Removed low-level grant CRUD endpoints — grants are now managed as part of role create/update
  • Consolidated access service implementation into single impl.go
  • Removed auto-provisioning of default grants in favor of a backfill script

Frontend

  • Roles table with system role badges and actions dropdown
  • Create/edit role dialog with scope picker using real project/MCP data via useListScopes API
  • Member assignment with before→after role transition badges
  • Org-level routing with feature flag guard
  • Sidebar "Get help" group (support, docs, changelog moved from header)

Database

  • principal_grants migration for fine-grained scope/resource grants
  • Removed unused roles and role_assignments tables (WorkOS manages these)

Test plan

  • Enable gram-rbac feature flag in PostHog
  • Verify "Roles & Permissions" nav item appears in org sidebar
  • Create a custom role with permissions and member assignments
  • Edit an existing role (system roles should have greyed-out name/description)
  • Change a member's role and verify the before→after badge transition
  • Delete a custom role
  • Verify 409 conflict toast when creating a duplicate role name
  • Verify system roles (admin, member) can be edited without 404 errors
  • Verify support/docs/changelog links appear in sidebar "Get help" group

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Mar 25, 2026 1:35pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 24, 2026

🦋 Changeset detected

Latest commit: 63f64e2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
server Minor
dashboard Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

atlas migrate lint on server/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@github-actions
Copy link
Copy Markdown
Contributor

atlas migrate lint on server/clickhouse/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@adaam2 adaam2 changed the title feat: RBAC roles & permissions with WorkOS integration feat: add RBAC roles and permissions with WorkOS integration Mar 24, 2026
@blacksmith-sh

This comment has been minimized.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@blacksmith-sh

This comment has been minimized.

devin-ai-integration[bot]

This comment was marked as resolved.

@adaam2 adaam2 changed the title feat: RBAC roles and permissions via WorkOS feat: add ui for role and role assignment management Mar 25, 2026
An empty resources array (user clicked "Specific projects" but
selected none) was treated identically to null (unrestricted),
silently inserting a wildcard "*" grant. Now:
- Server: nil = wildcard, empty slice = no grant inserted
- Frontend: scopes with empty resource lists are filtered out

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace http.DefaultClient (no timeout, no retries) with
retryablehttp.NewClient().StandardClient() — consistent with the
rest of the codebase. This adds automatic retries with backoff for
transient failures and a default timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
retryablehttp.NewClient().StandardClient() doesn't set a timeout
by default. Add an explicit 30s timeout to prevent indefinite
blocking if WorkOS is unresponsive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- Seed script: use Postgres array literal '{id1,id2}' cast to text[]
  instead of ARRAY[:'org_ids'] which produced a single concatenated
  string for multi-org seeding
- Submit button: count only effective grants (unrestricted or with
  selected resources) so the button disables when all scopes have
  empty resource lists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace sequential per-member GetUser API calls with a single
paginated ListUsers(organizationID) call. Returns a map for O(1)
lookup per membership. Eliminates N round-trips to WorkOS API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

System roles (admin, member) now have disabled checkboxes and hidden
resource pickers in the edit dialog, matching the existing behavior
of disabled name/description fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

deleteRole onSuccess was only invalidating the roles cache. When a
role is deleted, WorkOS reassigns members to a default role, so the
members cache must also be invalidated to avoid showing stale role
assignments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 0 new potential issues.

View 13 additional findings in Devin Review.

Open in Devin Review

Extract RoleProvider interface from concrete *workos.RoleClient so the
access service can be tested with an in-memory mock. Add configurable
endpoint to RoleClient for httptest-based testing of the actual HTTP
layer.

Service-layer tests cover CreateRole, UpdateRole, DeleteRole, ListRoles
including grant persistence, system role protection, and member counts.
RoleClient tests cover all 8 methods against a fake WorkOS HTTP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
{Slug: "build:write", Description: "Create and manage projects and deployments", ResourceType: "project"},
{Slug: "mcp:read", Description: "View MCP server configurations", ResourceType: "mcp"},
{Slug: "mcp:write", Description: "Create and manage MCP server configurations", ResourceType: "mcp"},
{Slug: "mcp:connect", Description: "Connect to and use MCP servers", ResourceType: "mcp"},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for later: these scopes will be defined in the access package I'm creating in my PR - we should use the constants from there.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 13 additional findings in Devin Review.

Open in Devin Review

@adaam2 adaam2 requested a review from qstearns March 25, 2026 13:45
// mcp:read, mcp:write, mcp:connect
// - Grants are additive (union), no deny
// - Each grant is unrestricted (resources=null) or allowlisted (resources=[...])
// ---------------------------------------------------------------------------
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably get rid of these comments - it's the type of comment that'll go stale quite fast

return "", fmt.Errorf("get organization metadata: %w", err)
}
if !org.WorkosID.Valid || org.WorkosID.String == "" {
return "", gen.MakeBadRequest(errors.New("organization is not linked to WorkOS"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe this is standard in our codebase - I can't see other places where we use this. You should be using oops here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should apply to all gen.Make clauses we have throughout.


Meta("openapi:operationId", "listRoles")
Meta("openapi:extension:x-speakeasy-name-override", "listRoles")
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListRoles"}`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListRoles"}`)
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Roles"}`)

Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Grants"}`)
Meta("openapi:operationId", "getRole")
Meta("openapi:extension:x-speakeasy-name-override", "getRole")
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "GetRole"}`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "GetRole"}`)
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Role"}`)


Meta("openapi:operationId", "listScopes")
Meta("openapi:extension:x-speakeasy-name-override", "listScopes")
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListScopes"}`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListScopes"}`)
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Scopes"}`)

Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "RemovePrincipalGrants"}`)
Meta("openapi:operationId", "listMembers")
Meta("openapi:extension:x-speakeasy-name-override", "listMembers")
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListMembers"}`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ListMembers"}`)
Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Members"}`)

Comment on lines +34 to +41
ListRoles(ctx context.Context, orgID string) ([]roles.Role, error)
CreateRole(ctx context.Context, orgID string, opts workos.CreateRoleOpts) (*roles.Role, error)
UpdateRole(ctx context.Context, orgID string, roleSlug string, opts workos.UpdateRoleOpts) (*roles.Role, error)
DeleteRole(ctx context.Context, orgID string, roleSlug string) error
ListMembers(ctx context.Context, orgID string) ([]usermanagement.OrganizationMembership, error)
UpdateMemberRole(ctx context.Context, membershipID string, roleSlug string) (*usermanagement.OrganizationMembership, error)
GetUser(ctx context.Context, userID string) (*usermanagement.User, error)
ListOrgUsers(ctx context.Context, orgID string) (map[string]usermanagement.User, error)
Copy link
Copy Markdown
Contributor

@disintegrator disintegrator Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the dependence on workos types makes this a leaky abstraction. can we do without it?

}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode >= 400 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to handle all errors the same way? 4xx and 5xx are different in nature.

Additionally: how will we be handling these upstream (what will the user see)?

}

var role roles.Role
if err := rc.doAPI(ctx, http.MethodPost, "/authorization/organizations/"+orgID+"/roles", body, &role); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does workos SDK not have this?


// doAPI performs a raw HTTP request against the WorkOS REST API.
// Used for endpoints not covered by the Go SDK (role CRUD).
func (rc *RoleClient) doAPI(ctx context.Context, method, path string, body []byte, out any) error {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for your knowledge, you could just have this method be called do (no need for doAPI). It's a common/well known go idiom.

Not blocking, just information

// mockRoleProvider is an in-memory implementation of access.RoleProvider for
// tests. It stores roles and memberships in maps so tests can exercise the
// service layer without hitting WorkOS.
type mockRoleProvider struct {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about our DB connections (please check it), but I wouldn't keep the mocks for testing in the same file as the setup_test - I'd expect setup test to be all about setting up the test (and any potential shared test methods). Ignore if this is how we typically do it.

if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // rollback after commit is a no-op; error is safe to ignore
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be using:

defer o11y.NoLogDefer(func() error { return dbtx.Rollback(ctx) })

return "", fmt.Errorf("get organization metadata: %w", err)
}
if !org.WorkosID.Valid || org.WorkosID.String == "" {
return "", gen.MakeBadRequest(errors.New("organization is not linked to WorkOS"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should apply to all gen.Make clauses we have throughout.

Description: p.Description,
})
if err != nil {
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "slug_conflict") || strings.Contains(err.Error(), "already in use") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the only way to check for WorkOS errors? I'd like to avoid these string comparisons if possible - they're a recipe for disaster. Tomorrow WorkOS changes the wording and our logic blows up (this happened with Polar recently)

return &gen.UpsertGrantsResult{Grants: grants}, nil
// Sync scope grants to local DB. If this fails, clean up the WorkOS role
// to avoid leaving an orphan.
if len(p.Grants) > 0 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: move this line to the syncGrants method. It will save you a nested if.

Inside syncGrants your first line can be:

if len(grants) == 0 { return nil }

tgmendes added a commit that referenced this pull request Mar 30, 2026
## Summary

- Adds `RoleClient` to the WorkOS third-party package wrapping WorkOS
role and membership APIs
- Supports listing, creating, updating, and deleting org-scoped roles
via raw HTTP
- Supports listing org members, updating member role assignments, and
fetching user details via WorkOS SDK
- Uses a retryable HTTP client (30s timeout) for resilience
- Exposes `NewRoleClientWithEndpoint` for hermetic testing against an
`httptest.Server`

Part of the RBAC feature split from #1970.

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/speakeasy-api/gram/pull/2011"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants