From 32ca94bb909b53388dfd74314aa6d383df2ab25a Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 27 Aug 2026 09:45:21 +0000 Subject: [PATCH 1/2] Add group-based RBAC for integrations, enforced at the runtime proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/admin/src/App.tsx | 2 + .../src/components/RouteDocumentTitle.tsx | 1 + apps/admin/src/components/SettingsLayout.tsx | 1 + apps/admin/src/lib/api.ts | 45 +++ apps/admin/src/pages/APIKeysPage.tsx | 3 + apps/admin/src/pages/TeamPage.tsx | 2 +- apps/admin/src/pages/UserGroupsPage.tsx | 226 +++++++++++ apps/admin/src/types.ts | 25 ++ platform-libraries/bun/integrations/index.ts | 4 + .../python/integrations/module.py | 4 + .../internal/api/handlers/access.go | 130 ++++++ .../api/handlers/configure_integrations.go | 8 + .../internal/api/handlers/connections.go | 119 +++++- .../internal/api/handlers/invocations.go | 21 +- .../internal/api/handlers/members.go | 7 +- .../internal/api/handlers/tenants.go | 9 +- .../internal/api/handlers/user_groups.go | 306 ++++++++++++++ .../internal/api/middleware/auth.go | 62 ++- .../internal/api/openapi/openapi.json | 38 +- services/control-plane/internal/api/router.go | 25 +- .../internal/api/user_groups_test.go | 269 +++++++++++++ .../internal/callerid/callerid.go | 92 +++++ .../internal/callerid/callerid_test.go | 85 ++++ .../control-plane/internal/models/apikey.go | 3 +- .../internal/models/user_group.go | 98 +++++ .../internal/scheduler/scheduler.go | 45 ++- .../internal/store/postgres/apikeys.go | 25 +- .../internal/store/postgres/db.go | 5 +- .../migrations/029_rbac_user_groups.sql | 55 +++ .../internal/store/postgres/user_groups.go | 381 ++++++++++++++++++ .../store/postgres/user_groups_test.go | 239 +++++++++++ 31 files changed, 2289 insertions(+), 46 deletions(-) create mode 100644 apps/admin/src/pages/UserGroupsPage.tsx create mode 100644 services/control-plane/internal/api/handlers/access.go create mode 100644 services/control-plane/internal/api/handlers/user_groups.go create mode 100644 services/control-plane/internal/api/user_groups_test.go create mode 100644 services/control-plane/internal/callerid/callerid.go create mode 100644 services/control-plane/internal/callerid/callerid_test.go create mode 100644 services/control-plane/internal/models/user_group.go create mode 100644 services/control-plane/internal/store/postgres/migrations/029_rbac_user_groups.sql create mode 100644 services/control-plane/internal/store/postgres/user_groups.go create mode 100644 services/control-plane/internal/store/postgres/user_groups_test.go diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 5cfe449..b8267b7 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -8,6 +8,7 @@ import SettingsLayout from './components/SettingsLayout' import OverviewPage from './pages/OverviewPage' import APIKeysPage from './pages/APIKeysPage' import TeamPage from './pages/TeamPage' +import UserGroupsPage from './pages/UserGroupsPage' import BrandingPage from './pages/BrandingPage' import EgressPage from './pages/EgressPage' import SnippetsPage from './pages/SnippetsPage' @@ -78,6 +79,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/RouteDocumentTitle.tsx b/apps/admin/src/components/RouteDocumentTitle.tsx index 84b89a5..f6425a2 100644 --- a/apps/admin/src/components/RouteDocumentTitle.tsx +++ b/apps/admin/src/components/RouteDocumentTitle.tsx @@ -13,6 +13,7 @@ const dashboardRoutes: { path: string; title: string }[] = [ { path: '/dashboard/data-store', title: 'Data Store' }, { path: '/dashboard/settings/api-keys', title: 'API Keys' }, { path: '/dashboard/settings/team', title: 'Team' }, + { path: '/dashboard/settings/groups', title: 'User Groups' }, { path: '/dashboard/settings/branding', title: 'Branding' }, { path: '/dashboard/settings/egress', title: 'Egress Policy' }, { path: '/dashboard/settings/embed', title: 'Embed' }, diff --git a/apps/admin/src/components/SettingsLayout.tsx b/apps/admin/src/components/SettingsLayout.tsx index 3f3a9af..f5eafea 100644 --- a/apps/admin/src/components/SettingsLayout.tsx +++ b/apps/admin/src/components/SettingsLayout.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' const tabs = [ { to: '/dashboard/settings/api-keys', label: 'API Keys' }, { to: '/dashboard/settings/team', label: 'Team' }, + { to: '/dashboard/settings/groups', label: 'User Groups' }, { to: '/dashboard/settings/branding', label: 'Branding' }, { to: '/dashboard/settings/egress', label: 'Egress Policy' }, { to: '/dashboard/settings/embed', label: 'Embed' }, diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts index 340d9ba..25332e7 100644 --- a/apps/admin/src/lib/api.ts +++ b/apps/admin/src/lib/api.ts @@ -3,6 +3,8 @@ import type { OrgMembership, Branding, TenantMember, + UserGroup, + IntegrationGroupGrant, InviteToken, UsageSummary, APIKey, @@ -463,6 +465,49 @@ export const api = { return request('GET', '/v1/tenant/members/invites', undefined, 'apikey') }, + // User groups + async listUserGroups(): Promise { + return request('GET', '/v1/tenant/groups', undefined, 'apikey') + }, + + async createUserGroup(name: string, description: string): Promise { + return request('POST', '/v1/tenant/groups', { name, description }, 'apikey') + }, + + async deleteUserGroup(groupID: string): Promise { + return request('DELETE', `/v1/tenant/groups/${groupID}`, undefined, 'apikey') + }, + + async addUserGroupMember(groupID: string, userID: string): Promise { + return request('POST', `/v1/tenant/groups/${groupID}/members`, { user_id: userID }, 'apikey') + }, + + async removeUserGroupMember(groupID: string, userID: string): Promise { + return request('DELETE', `/v1/tenant/groups/${groupID}/members`, { user_id: userID }, 'apikey') + }, + + async grantIntegrationToGroup(groupID: string, credentialProfileID: string): Promise { + return request( + 'POST', + `/v1/tenant/groups/${groupID}/integrations`, + { credential_profile_id: credentialProfileID }, + 'apikey', + ) + }, + + async revokeIntegrationFromGroup(groupID: string, credentialProfileID: string): Promise { + return request( + 'DELETE', + `/v1/tenant/groups/${groupID}/integrations`, + { credential_profile_id: credentialProfileID }, + 'apikey', + ) + }, + + async listIntegrationGrants(credentialProfileID: string): Promise { + return request('GET', `/v1/integrations/configured/${credentialProfileID}/groups`, undefined, 'apikey') + }, + // Usage async getUsage(window: string): Promise { return request('GET', `/v1/tenant/usage?window=${window}`, undefined, 'apikey') diff --git a/apps/admin/src/pages/APIKeysPage.tsx b/apps/admin/src/pages/APIKeysPage.tsx index 1643155..24fd146 100644 --- a/apps/admin/src/pages/APIKeysPage.tsx +++ b/apps/admin/src/pages/APIKeysPage.tsx @@ -113,6 +113,9 @@ export default function APIKeysPage() { ))} +

+ The key inherits your user groups, so it can only use integrations granted to them. +

+ + + + {loading ? ( +

Loading...

+ ) : groups.length === 0 ? ( +

No groups yet.

+ ) : ( +
+ {groups.map((group) => ( +
+
+
+

{group.name}

+ {group.description && ( +

{group.description}

+ )} +
+ +
+ +
+
+

Members

+
+ {(group.members ?? []).length === 0 ? ( + No members yet. + ) : ( + (group.members ?? []).map((m) => ( + + {m.email || m.user_id} + + + )) + )} +
+ +
+ +
+

Integrations

+
+ {(group.integration_grants ?? []).length === 0 ? ( + No integrations granted. + ) : ( + (group.integration_grants ?? []).map((g) => ( + + {integrationLabel(g.credential_profile_id)} + + + )) + )} +
+ +
+
+
+ ))} +
+ )} + + ) +} diff --git a/apps/admin/src/types.ts b/apps/admin/src/types.ts index 2f416f4..09b0374 100644 --- a/apps/admin/src/types.ts +++ b/apps/admin/src/types.ts @@ -48,6 +48,31 @@ export interface TenantMember { invited_at: string } +export interface UserGroupMember { + group_id: string + user_id: string + email?: string + added_at: string +} + +export interface IntegrationGroupGrant { + group_id: string + group_name?: string + credential_profile_id: string + granted_at: string +} + +export interface UserGroup { + id: string + tenant_id: string + name: string + description: string + created_at: string + updated_at: string + members: UserGroupMember[] | null + integration_grants: IntegrationGroupGrant[] | null +} + export interface InviteToken { id: string tenant_id: string diff --git a/platform-libraries/bun/integrations/index.ts b/platform-libraries/bun/integrations/index.ts index 1c32093..b9f2426 100644 --- a/platform-libraries/bun/integrations/index.ts +++ b/platform-libraries/bun/integrations/index.ts @@ -20,6 +20,9 @@ const PROXY_URL = process.env.VELANE_PROXY_URL ?? '' const TENANT_ID = process.env.VELANE_TENANT_ID ?? '' +// Signed by the control plane; carries the invoking user's group access so the +// proxy can enforce integration grants. Opaque and unforgeable from here. +const CALLER_TOKEN = process.env.VELANE_CALLER_TOKEN ?? '' export class IntegrationClient { constructor( @@ -37,6 +40,7 @@ export class IntegrationClient { headers: { 'Content-Type': 'application/json', 'X-Velane-Tenant': TENANT_ID, + ...(CALLER_TOKEN ? { 'X-Velane-Caller': CALLER_TOKEN } : {}), ...(this.opts?.alias ? { 'X-Velane-Integration-Alias': this.opts.alias } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, diff --git a/platform-libraries/python/integrations/module.py b/platform-libraries/python/integrations/module.py index a13f884..cda95e3 100644 --- a/platform-libraries/python/integrations/module.py +++ b/platform-libraries/python/integrations/module.py @@ -25,6 +25,9 @@ _PROXY_URL = os.environ.get("VELANE_PROXY_URL", "") _TENANT_ID = os.environ.get("VELANE_TENANT_ID", "") +# Signed by the control plane; carries the invoking user's group access so the +# proxy can enforce integration grants. Opaque and unforgeable from here. +_CALLER_TOKEN = os.environ.get("VELANE_CALLER_TOKEN", "") class IntegrationClient: @@ -47,6 +50,7 @@ def _req(self, method: str, endpoint: str, body=None): headers={ "Content-Type": "application/json", "X-Velane-Tenant": _TENANT_ID, + **({"X-Velane-Caller": _CALLER_TOKEN} if _CALLER_TOKEN else {}), **({"X-Velane-Integration-Alias": self._alias} if self._alias else {}), }, ) diff --git a/services/control-plane/internal/api/handlers/access.go b/services/control-plane/internal/api/handlers/access.go new file mode 100644 index 0000000..11535db --- /dev/null +++ b/services/control-plane/internal/api/handlers/access.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/abskrj/velane/services/control-plane/internal/api/middleware" + "github.com/abskrj/velane/services/control-plane/internal/models" + "github.com/abskrj/velane/services/control-plane/internal/store/postgres" +) + +// CallerAccess is the integration visibility of the principal behind a request. +// +// A credential profile that has no group grant at all stays visible to the whole +// tenant, so enabling RBAC does not retroactively hide existing integrations. +// Once a profile is granted to at least one group it becomes restricted and only +// admins/owners and members of a granted group may see or use it. +type CallerAccess struct { + Identity models.CallerIdentity + // SeesAll short-circuits every check (admin/owner, or a legacy tenant-wide + // API key while the tenant has not enabled strict mode). + SeesAll bool + + granted map[string]bool + restricted map[string]bool +} + +// CanUseProfile reports whether the caller may see or use a credential profile. +func (a *CallerAccess) CanUseProfile(profileID string) bool { + if a == nil || a.SeesAll { + return true + } + if profileID == "" { + // Connections predating credential profiles are not group-restricted. + return true + } + if !a.restricted[profileID] { + return true + } + return a.granted[profileID] +} + +// ResolveCallerAccess derives the caller's identity and integration visibility +// from the request context, which SessionAuth and Auth have already populated. +func ResolveCallerAccess(ctx context.Context, store *postgres.Store, tenantID string) (*CallerAccess, error) { + identity := models.CallerIdentity{TenantID: tenantID, Role: middleware.CallerRole(ctx)} + + if user := middleware.SessionUserFromContext(ctx); user != nil { + identity.UserID = user.ID + } else if key := middleware.APIKeyFromContext(ctx); key != nil && key.UserID != nil { + identity.UserID = *key.UserID + } + + return resolveAccessForIdentity(ctx, store, identity) +} + +func resolveAccessForIdentity(ctx context.Context, store *postgres.Store, identity models.CallerIdentity) (*CallerAccess, error) { + access := &CallerAccess{Identity: identity, granted: map[string]bool{}, restricted: map[string]bool{}} + + if identity.SeesAllIntegrations() { + access.SeesAll = true + return access, nil + } + + if identity.UserID == "" { + // Legacy tenant-wide credential with no owning user. Backward compatible + // by default; tenants that opt into strict mode lose access to every + // group-restricted profile instead. + strict, err := store.TenantRBACStrictMode(ctx, identity.TenantID) + if err != nil { + return nil, err + } + if !strict { + access.SeesAll = true + return access, nil + } + } + + if identity.UserID != "" && len(identity.GroupIDs) == 0 { + groupIDs, err := store.ListGroupIDsForUser(ctx, identity.TenantID, identity.UserID) + if err != nil { + return nil, err + } + identity.GroupIDs = groupIDs + access.Identity = identity + } + + restricted, err := store.ListRestrictedCredentialProfileIDs(ctx, identity.TenantID) + if err != nil { + return nil, err + } + for _, id := range restricted { + access.restricted[id] = true + } + + granted, err := store.ListCredentialProfileIDsForGroups(ctx, identity.TenantID, identity.GroupIDs) + if err != nil { + return nil, err + } + for _, id := range granted { + access.granted[id] = true + } + return access, nil +} + +// apiKeyRole maps an API key's scopes back onto the equivalent tenant role so +// that admin-scoped keys keep seeing every integration. +func apiKeyRole(key *models.APIKey) string { + switch { + case key == nil: + return "" + case key.HasScope(models.RoleAdmin): + return models.RoleAdmin + case key.HasScope(models.RoleManage): + return models.RoleManage + case key.HasScope(models.RoleInvoke): + return models.RoleInvoke + } + return "" +} + +// callerAccessOrError resolves access and writes a 500 when resolution fails. +func callerAccessOrError(w http.ResponseWriter, r *http.Request, store *postgres.Store, tenantID string) (*CallerAccess, bool) { + access, err := ResolveCallerAccess(r.Context(), store, tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve integration access") + return nil, false + } + return access, true +} diff --git a/services/control-plane/internal/api/handlers/configure_integrations.go b/services/control-plane/internal/api/handlers/configure_integrations.go index d7afce2..0fff70c 100644 --- a/services/control-plane/internal/api/handlers/configure_integrations.go +++ b/services/control-plane/internal/api/handlers/configure_integrations.go @@ -67,11 +67,19 @@ func (h *ConfigureIntegrationsHandler) ListConfigured(w http.ResponseWriter, r * connectedProfiles[strings.ToLower(conn.Provider)+"::"+strings.ToLower(conn.Alias)] = struct{}{} } + access, ok := callerAccessOrError(w, r, h.store, tenant.ID) + if !ok { + return + } + filtered := make([]configuredProfileResponse, 0, len(configs)) for _, c := range configs { if c == nil { continue } + if !access.CanUseProfile(c.ID) { + continue + } key := strings.ToLower(c.Provider) + "::" + strings.ToLower(c.Alias) _, isConnected := connectedProfiles[key] diff --git a/services/control-plane/internal/api/handlers/connections.go b/services/control-plane/internal/api/handlers/connections.go index a851319..6fa2e1a 100644 --- a/services/control-plane/internal/api/handlers/connections.go +++ b/services/control-plane/internal/api/handlers/connections.go @@ -8,6 +8,7 @@ import ( "github.com/abskrj/velane/services/control-plane/internal/api/middleware" "github.com/abskrj/velane/services/control-plane/internal/audit" + "github.com/abskrj/velane/services/control-plane/internal/callerid" "github.com/abskrj/velane/services/control-plane/internal/models" "github.com/abskrj/velane/services/control-plane/internal/nango" "github.com/abskrj/velane/services/control-plane/internal/store/postgres" @@ -32,6 +33,7 @@ type ConnectionsHandler struct { nango *nango.Client log *zap.Logger auditor *audit.Logger + callerKey []byte // verifies signed caller identity tokens at the proxy nangoConnectURL string // browser-accessible Connect UI URL, returned with session tokens nangoApiURL string // browser-accessible Nango API URL, returned with session tokens } @@ -45,6 +47,14 @@ func (h *ConnectionsHandler) WithAuditor(a *audit.Logger) *ConnectionsHandler { return h } +// WithCallerKey installs the key used to verify the caller identity tokens the +// scheduler mints. Without it the proxy cannot identify callers and keeps the +// pre-RBAC behaviour for unrestricted profiles. +func (h *ConnectionsHandler) WithCallerKey(key []byte) *ConnectionsHandler { + h.callerKey = append([]byte(nil), key...) + return h +} + // CreateSession handles POST /v1/tenant/connections/session. // Returns a short-lived Nango Connect session token the frontend uses to open // the OAuth popup for a specific provider. @@ -222,7 +232,13 @@ func (h *ConnectionsHandler) ListConnections(w http.ResponseWriter, r *http.Requ return } - if conns == nil { + access, ok := callerAccessOrError(w, r, h.store, tenant.ID) + if !ok { + return + } + conns = filterConnectionsByAccess(conns, access) + + if len(conns) == 0 { writeJSON(w, http.StatusOK, []any{}) return } @@ -289,13 +305,38 @@ func (h *ConnectionsHandler) ListConnectionsForToken(w http.ResponseWriter, r *h return } - if conns == nil { + access, ok := callerAccessOrError(w, r, h.store, tenant.ID) + if !ok { + return + } + conns = filterConnectionsByAccess(conns, access) + + if len(conns) == 0 { writeJSON(w, http.StatusOK, []any{}) return } writeJSON(w, http.StatusOK, filterAndPaginateConnections(conns, r)) } +// filterConnectionsByAccess drops connections whose credential profile is +// restricted to groups the caller does not belong to. +func filterConnectionsByAccess(conns []*models.Connection, access *CallerAccess) []*models.Connection { + if access == nil || access.SeesAll { + return conns + } + visible := make([]*models.Connection, 0, len(conns)) + for _, c := range conns { + profileID := "" + if c.CredentialProfileID != nil { + profileID = *c.CredentialProfileID + } + if access.CanUseProfile(profileID) { + visible = append(visible, c) + } + } + return visible +} + func filterAndPaginateConnections(conns []*models.Connection, r *http.Request) []*models.Connection { searchQuery := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) limit := parsePositiveInt(r.URL.Query().Get("limit"), 100) @@ -333,6 +374,10 @@ func filterAndPaginateConnections(conns []*models.Connection, r *http.Request) [ // and trusts the X-Velane-Tenant header. It is externally reachable in shipped // topologies, so callers that reach this port can name any tenant. Moving // trust-header routes to a second, never-published listener is tracked separately. +// +// Group membership, unlike the tenant header, is never taken on trust: it comes +// from the signed caller token the scheduler injects into the sandbox, so a +// snippet cannot widen its own integration access. func (h *ConnectionsHandler) Proxy(w http.ResponseWriter, r *http.Request) { tenantID := strings.TrimSpace(r.Header.Get("X-Velane-Tenant")) if tenantID == "" { @@ -364,5 +409,75 @@ func (h *ConnectionsHandler) Proxy(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "connection is not fully linked; reconnect provider") return } + if !h.callerMayUseConnection(r, tenantID, conn) { + writeError(w, http.StatusForbidden, "integration not granted to caller's groups") + return + } h.nango.Proxy(w, r, conn.NangoConnectionID, providerConfigKey, path) } + +// callerMayUseConnection verifies the signed caller token against the group +// grants on the connection's credential profile. A missing or unverifiable +// token keeps the pre-RBAC behaviour unless the tenant enabled strict mode. +func (h *ConnectionsHandler) callerMayUseConnection(r *http.Request, tenantID string, conn *models.Connection) bool { + profileID := "" + if conn.CredentialProfileID != nil { + profileID = *conn.CredentialProfileID + } + + restricted := false + if profileID != "" { + var err error + restricted, err = h.store.CredentialProfileHasGrants(r.Context(), profileID) + if err != nil { + h.log.Error("proxy: check credential profile grants", zap.Error(err)) + return false + } + } + + claims := h.verifiedCallerClaims(r, tenantID) + if claims == nil { + if !restricted { + return true + } + strict, err := h.store.TenantRBACStrictMode(r.Context(), tenantID) + if err != nil { + h.log.Error("proxy: read tenant strict mode", zap.Error(err)) + return false + } + return !strict + } + + if models.RoleSeesAllIntegrations(claims.Role) || !restricted { + return true + } + + granted, err := h.store.CredentialProfileGrantedToGroups(r.Context(), profileID, claims.GroupIDs) + if err != nil { + h.log.Error("proxy: check group grant", zap.Error(err)) + return false + } + return granted +} + +// verifiedCallerClaims returns the caller identity only when the token is +// present, correctly signed and issued for this tenant. +func (h *ConnectionsHandler) verifiedCallerClaims(r *http.Request, tenantID string) *callerid.Claims { + token := strings.TrimSpace(r.Header.Get(callerid.Header)) + if token == "" || len(h.callerKey) == 0 { + return nil + } + claims, err := callerid.Verify(h.callerKey, token) + if err != nil { + h.log.Warn("proxy: rejecting unverifiable caller token", zap.Error(err)) + return nil + } + if claims.TenantID != tenantID { + h.log.Warn("proxy: caller token tenant mismatch", + zap.String("token_tenant", claims.TenantID), + zap.String("header_tenant", tenantID), + ) + return nil + } + return claims +} diff --git a/services/control-plane/internal/api/handlers/invocations.go b/services/control-plane/internal/api/handlers/invocations.go index b5466ff..f331898 100644 --- a/services/control-plane/internal/api/handlers/invocations.go +++ b/services/control-plane/internal/api/handlers/invocations.go @@ -78,6 +78,7 @@ func (h *InvocationsHandler) InvokeByToken(w http.ResponseWriter, r *http.Reques } var tenant *models.Tenant + var caller models.CallerIdentity // Try session JWT auth when an auth provider is configured. if h.provider != nil { @@ -88,7 +89,7 @@ func (h *InvocationsHandler) InvokeByToken(w http.ResponseWriter, r *http.Reques return } activeMembership := memberships[0] - if role := activeMembership.Role; role != "invoke" && role != "manage" && role != "admin" { + if !models.RoleGrantsScope(activeMembership.Role, models.RoleInvoke) { writeError(w, http.StatusForbidden, "insufficient role for invoke") return } @@ -98,6 +99,7 @@ func (h *InvocationsHandler) InvokeByToken(w http.ResponseWriter, r *http.Reques return } tenant = t + caller = models.CallerIdentity{TenantID: t.ID, UserID: user.ID, Role: activeMembership.Role} goto authOKByToken } } @@ -121,10 +123,26 @@ func (h *InvocationsHandler) InvokeByToken(w http.ResponseWriter, r *http.Reques return } tenant = t + caller = models.CallerIdentity{TenantID: t.ID, Role: apiKeyRole(key)} + if key.UserID != nil { + caller.UserID = *key.UserID + } } authOKByToken: + // Resolve the caller's groups once; the scheduler signs them into the + // sandbox so the proxy can enforce integration grants at runtime. + if caller.UserID != "" { + groupIDs, err := h.store.ListGroupIDsForUser(r.Context(), tenant.ID, caller.UserID) + if err != nil { + h.log.Error("invoke: resolve caller groups", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to resolve caller groups") + return + } + caller.GroupIDs = groupIDs + } + // --- Read the input payload --- body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { @@ -175,6 +193,7 @@ authOKByToken: Env: env, Input: inputPayload, PinnedVersion: pinnedVersion, + Caller: caller, } // --- Invoke mode --- diff --git a/services/control-plane/internal/api/handlers/members.go b/services/control-plane/internal/api/handlers/members.go index 36b8331..33f7d9e 100644 --- a/services/control-plane/internal/api/handlers/members.go +++ b/services/control-plane/internal/api/handlers/members.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "net/http" + "strings" "time" "github.com/abskrj/velane/services/control-plane/internal/api/middleware" @@ -85,10 +86,8 @@ func (h *MembersHandler) InviteMember(w http.ResponseWriter, r *http.Request) { if req.Role == "" { req.Role = "manage" } - switch req.Role { - case "invoke", "manage", "admin": - default: - writeError(w, http.StatusBadRequest, "role must be one of: invoke, manage, admin") + if !models.IsValidRole(req.Role) { + writeError(w, http.StatusBadRequest, "role must be one of: "+strings.Join(models.ValidRoles, ", ")) return } diff --git a/services/control-plane/internal/api/handlers/tenants.go b/services/control-plane/internal/api/handlers/tenants.go index 23a6227..60c1f60 100644 --- a/services/control-plane/internal/api/handlers/tenants.go +++ b/services/control-plane/internal/api/handlers/tenants.go @@ -98,7 +98,14 @@ func (h *TenantsHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { req.Scopes = []string{"invoke"} } - key, plain, err := h.store.CreateAPIKeyWithPlain(r.Context(), tenant.ID, req.Name, req.Scopes) + // A key created by a signed-in user inherits that user's group access; keys + // created with an API key stay tenant-wide as before. + var ownerID *string + if user := middleware.SessionUserFromContext(r.Context()); user != nil { + ownerID = &user.ID + } + + key, plain, err := h.store.CreateAPIKeyWithPlainForUser(r.Context(), tenant.ID, req.Name, req.Scopes, ownerID) if err != nil { h.log.Error("create api key failed", zap.Error(err)) writeError(w, http.StatusInternalServerError, "failed to create api key") diff --git a/services/control-plane/internal/api/handlers/user_groups.go b/services/control-plane/internal/api/handlers/user_groups.go new file mode 100644 index 0000000..bd157c2 --- /dev/null +++ b/services/control-plane/internal/api/handlers/user_groups.go @@ -0,0 +1,306 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/abskrj/velane/services/control-plane/internal/api/middleware" + "github.com/abskrj/velane/services/control-plane/internal/audit" + "github.com/abskrj/velane/services/control-plane/internal/models" + "github.com/abskrj/velane/services/control-plane/internal/store/postgres" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +// UserGroupsHandler manages tenant user groups and the integration grants +// attached to them. +type UserGroupsHandler struct { + store *postgres.Store + log *zap.Logger + auditor *audit.Logger +} + +func NewUserGroupsHandler(store *postgres.Store, log *zap.Logger) *UserGroupsHandler { + return &UserGroupsHandler{store: store, log: log} +} + +func (h *UserGroupsHandler) WithAuditor(a *audit.Logger) *UserGroupsHandler { + h.auditor = a + return h +} + +type userGroupResponse struct { + *models.UserGroup + Members []*models.UserGroupMember `json:"members"` + Grants []*models.IntegrationGroupGrant `json:"integration_grants"` +} + +// ListGroups handles GET /v1/tenant/groups. +func (h *UserGroupsHandler) ListGroups(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groups, err := h.store.ListUserGroups(r.Context(), tenant.ID) + if err != nil { + h.log.Error("list user groups failed", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to list user groups") + return + } + + out := make([]userGroupResponse, 0, len(groups)) + for _, g := range groups { + members, err := h.store.ListUserGroupMembers(r.Context(), tenant.ID, g.ID) + if err != nil { + h.log.Error("list user group members failed", zap.String("group_id", g.ID), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to list user groups") + return + } + grants, err := h.store.ListGrantsForGroup(r.Context(), tenant.ID, g.ID) + if err != nil { + h.log.Error("list group grants failed", zap.String("group_id", g.ID), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to list user groups") + return + } + out = append(out, userGroupResponse{UserGroup: g, Members: members, Grants: grants}) + } + + writeJSON(w, http.StatusOK, out) +} + +// CreateGroup handles POST /v1/tenant/groups. +func (h *UserGroupsHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + group, err := h.store.CreateUserGroup(r.Context(), tenant.ID, req.Name, strings.TrimSpace(req.Description)) + if err != nil { + h.log.Error("create user group failed", zap.Error(err)) + writeError(w, http.StatusBadRequest, "failed to create user group") + return + } + + h.audit(r, tenant.ID, "user_group_create", group.ID, map[string]any{"name": group.Name}) + writeJSON(w, http.StatusCreated, userGroupResponse{ + UserGroup: group, + Members: []*models.UserGroupMember{}, + Grants: []*models.IntegrationGroupGrant{}, + }) +} + +// DeleteGroup handles DELETE /v1/tenant/groups/{groupID}. +func (h *UserGroupsHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groupID := chi.URLParam(r, "groupID") + if err := h.store.DeleteUserGroup(r.Context(), tenant.ID, groupID); err != nil { + writeError(w, http.StatusNotFound, "user group not found") + return + } + + h.audit(r, tenant.ID, "user_group_delete", groupID, nil) + w.WriteHeader(http.StatusNoContent) +} + +// AddMember handles POST /v1/tenant/groups/{groupID}/members. +func (h *UserGroupsHandler) AddMember(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groupID := chi.URLParam(r, "groupID") + userID, ok := h.resolveUserID(w, r) + if !ok { + return + } + + if err := h.store.AddUserGroupMember(r.Context(), tenant.ID, groupID, userID); err != nil { + writeError(w, http.StatusNotFound, "user group not found") + return + } + + h.audit(r, tenant.ID, "user_group_member_add", groupID, map[string]any{"user_id": userID}) + writeJSON(w, http.StatusCreated, map[string]string{"group_id": groupID, "user_id": userID}) +} + +// RemoveMember handles DELETE /v1/tenant/groups/{groupID}/members. +func (h *UserGroupsHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groupID := chi.URLParam(r, "groupID") + userID, ok := h.resolveUserID(w, r) + if !ok { + return + } + + if err := h.store.RemoveUserGroupMember(r.Context(), tenant.ID, groupID, userID); err != nil { + h.log.Error("remove user group member failed", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to remove group member") + return + } + + h.audit(r, tenant.ID, "user_group_member_remove", groupID, map[string]any{"user_id": userID}) + w.WriteHeader(http.StatusNoContent) +} + +// GrantIntegration handles POST /v1/tenant/groups/{groupID}/integrations. +func (h *UserGroupsHandler) GrantIntegration(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groupID := chi.URLParam(r, "groupID") + profileID, ok := h.decodeProfileID(w, r) + if !ok { + return + } + + if err := h.store.GrantCredentialProfileToGroup(r.Context(), tenant.ID, groupID, profileID); err != nil { + writeError(w, http.StatusNotFound, "user group or credential profile not found") + return + } + + h.audit(r, tenant.ID, "user_group_integration_grant", groupID, map[string]any{"credential_profile_id": profileID}) + writeJSON(w, http.StatusCreated, map[string]string{"group_id": groupID, "credential_profile_id": profileID}) +} + +// RevokeIntegration handles DELETE /v1/tenant/groups/{groupID}/integrations. +func (h *UserGroupsHandler) RevokeIntegration(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + groupID := chi.URLParam(r, "groupID") + profileID, ok := h.decodeProfileID(w, r) + if !ok { + return + } + + if err := h.store.RevokeCredentialProfileFromGroup(r.Context(), tenant.ID, groupID, profileID); err != nil { + h.log.Error("revoke integration grant failed", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to revoke integration grant") + return + } + + h.audit(r, tenant.ID, "user_group_integration_revoke", groupID, map[string]any{"credential_profile_id": profileID}) + w.WriteHeader(http.StatusNoContent) +} + +// ListIntegrationGrants handles GET /v1/integrations/configured/{profileID}/groups. +func (h *UserGroupsHandler) ListIntegrationGrants(w http.ResponseWriter, r *http.Request) { + tenant := middleware.TenantFromContext(r.Context()) + if tenant == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + grants, err := h.store.ListGrantsForCredentialProfile(r.Context(), tenant.ID, chi.URLParam(r, "profileID")) + if err != nil { + h.log.Error("list profile grants failed", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to list integration grants") + return + } + writeJSON(w, http.StatusOK, grants) +} + +// resolveUserID accepts either a user_id or an email and resolves it to a user +// that belongs to the authenticated tenant. +func (h *UserGroupsHandler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) { + tenant := middleware.TenantFromContext(r.Context()) + + var req struct { + UserID string `json:"user_id"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return "", false + } + req.UserID = strings.TrimSpace(req.UserID) + req.Email = strings.TrimSpace(req.Email) + if req.UserID == "" && req.Email == "" { + writeError(w, http.StatusBadRequest, "user_id or email is required") + return "", false + } + + if req.UserID == "" { + user, err := h.store.GetUserByEmail(r.Context(), req.Email) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return "", false + } + req.UserID = user.ID + } + + // Group membership must never span tenants. + if _, err := h.store.GetMemberRole(r.Context(), tenant.ID, req.UserID); err != nil { + writeError(w, http.StatusNotFound, "user is not a member of this tenant") + return "", false + } + return req.UserID, true +} + +func (h *UserGroupsHandler) decodeProfileID(w http.ResponseWriter, r *http.Request) (string, bool) { + var req struct { + CredentialProfileID string `json:"credential_profile_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return "", false + } + req.CredentialProfileID = strings.TrimSpace(req.CredentialProfileID) + if req.CredentialProfileID == "" { + writeError(w, http.StatusBadRequest, "credential_profile_id is required") + return "", false + } + return req.CredentialProfileID, true +} + +func (h *UserGroupsHandler) audit(r *http.Request, tenantID, action, resourceID string, meta map[string]any) { + if h.auditor == nil { + return + } + actorID, actorType := resolveActor(r) + h.auditor.Log(r.Context(), models.AuditEntry{ + TenantID: tenantID, + ActorID: actorID, + ActorType: actorType, + Action: action, + ResourceID: resourceID, + Metadata: auditMeta(meta), + }) +} diff --git a/services/control-plane/internal/api/middleware/auth.go b/services/control-plane/internal/api/middleware/auth.go index ccad9c6..e35925d 100644 --- a/services/control-plane/internal/api/middleware/auth.go +++ b/services/control-plane/internal/api/middleware/auth.go @@ -138,20 +138,58 @@ func RequireScope(scope string, log *zap.Logger) func(http.Handler) http.Handler } } +// RequireRole returns a middleware that restricts a route to the given tenant +// member roles. API keys are matched against the equivalent scope-derived role +// so that admin-scoped keys keep working on admin-only routes. +func RequireRole(log *zap.Logger, roles ...string) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(roles)) + for _, role := range roles { + allowed[role] = true + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role := CallerRole(r.Context()) + if role == "" { + writeUnauthorized(w, "missing or malformed Authorization header") + return + } + if !allowed[role] { + http.Error(w, `{"error":"forbidden: insufficient role"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// CallerRole reports the effective tenant role of the request, whether it was +// authenticated with a session JWT or an API key. API keys have scopes rather +// than roles, so their broadest scope is mapped back onto a role. +func CallerRole(ctx context.Context) string { + if role := SessionRoleFromContext(ctx); role != "" { + return role + } + if key := APIKeyFromContext(ctx); key != nil { + switch { + case key.HasScope(models.RoleAdmin): + return models.RoleAdmin + case key.HasScope(models.RoleManage): + return models.RoleManage + case key.HasScope(models.RoleInvoke): + return models.RoleInvoke + } + } + return "" +} + // roleHasScope maps a tenant member role to the scopes it grants. -// admin → invoke + manage + admin -// manage → invoke + manage -// invoke → invoke only +// owner, admin → invoke + manage + admin +// integration_manager → invoke + manage +// manage → invoke + manage +// member, invoke → invoke only +// viewer → read-only, no scope func roleHasScope(role, scope string) bool { - switch role { - case "admin": - return true - case "manage": - return scope == "invoke" || scope == "manage" - case "invoke": - return scope == "invoke" - } - return false + return models.RoleGrantsScope(role, scope) } // bearerToken extracts the token from "Authorization: Bearer ". diff --git a/services/control-plane/internal/api/openapi/openapi.json b/services/control-plane/internal/api/openapi/openapi.json index 84d089d..ea04f4e 100644 --- a/services/control-plane/internal/api/openapi/openapi.json +++ b/services/control-plane/internal/api/openapi/openapi.json @@ -139,6 +139,24 @@ "/v1/tenant/members/invites": { "get": { "operationId": "listInvites", "summary": "List pending tenant invites", "tags": ["Tenants"], "x-required-scope": "admin", "responses": { "200": { "description": "Pending invites", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Invite" } } } } } } } }, + "/v1/tenant/groups": { + "get": { "operationId": "listUserGroups", "summary": "List tenant user groups", "tags": ["Tenants"], "x-required-scope": "manage", "responses": { "200": { "description": "User groups with their members and integration grants", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserGroup" } } } } }, "403": { "$ref": "#/components/responses/Forbidden" } } }, + "post": { "operationId": "createUserGroup", "summary": "Create a user group", "description": "Requires the admin or owner role.", "tags": ["Tenants"], "x-required-scope": "admin", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateUserGroupRequest" } } } }, "responses": { "201": { "description": "Created user group", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserGroup" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "403": { "$ref": "#/components/responses/Forbidden" } } } + }, + "/v1/tenant/groups/{groupID}": { + "parameters": [{ "$ref": "#/components/parameters/GroupID" }], + "delete": { "operationId": "deleteUserGroup", "summary": "Delete a user group", "description": "Requires the admin or owner role.", "tags": ["Tenants"], "x-required-scope": "admin", "responses": { "204": { "$ref": "#/components/responses/NoContent" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" } } } + }, + "/v1/tenant/groups/{groupID}/members": { + "parameters": [{ "$ref": "#/components/parameters/GroupID" }], + "post": { "operationId": "addUserGroupMember", "summary": "Add a member to a user group", "description": "Requires the admin or owner role.", "tags": ["Tenants"], "x-required-scope": "admin", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserGroupMemberRequest" } } } }, "responses": { "201": { "description": "Member added", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserGroupMember" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" } } }, + "delete": { "operationId": "removeUserGroupMember", "summary": "Remove a member from a user group", "description": "Requires the admin or owner role.", "tags": ["Tenants"], "x-required-scope": "admin", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserGroupMemberRequest" } } } }, "responses": { "204": { "$ref": "#/components/responses/NoContent" }, "400": { "$ref": "#/components/responses/BadRequest" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" } } } + }, + "/v1/tenant/groups/{groupID}/integrations": { + "parameters": [{ "$ref": "#/components/parameters/GroupID" }], + "post": { "operationId": "grantIntegrationToGroup", "summary": "Grant an integration credential profile to a user group", "description": "Requires the admin, owner or integration_manager role.", "tags": ["Integrations"], "x-required-scope": "manage", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationGrantRequest" } } } }, "responses": { "201": { "description": "Grant created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationGroupGrant" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" } } }, + "delete": { "operationId": "revokeIntegrationFromGroup", "summary": "Revoke an integration credential profile from a user group", "description": "Requires the admin, owner or integration_manager role.", "tags": ["Integrations"], "x-required-scope": "manage", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationGrantRequest" } } } }, "responses": { "204": { "$ref": "#/components/responses/NoContent" }, "400": { "$ref": "#/components/responses/BadRequest" }, "403": { "$ref": "#/components/responses/Forbidden" } } } + }, "/v1/tenant/plan": { "get": { "operationId": "getTenantPlan", "summary": "Get tenant plan and entitlements", "tags": ["Tenants"], "responses": { "200": { "description": "Tenant plan", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TenantPlan" } } } } } } }, @@ -192,6 +210,10 @@ "parameters": [{ "name": "providerConfigKey", "in": "path", "required": true, "schema": { "type": "string" } }], "delete": { "operationId": "deleteConfiguredIntegration", "summary": "Delete an integration credential profile", "tags": ["Integrations"], "x-required-scope": "manage", "responses": { "204": { "$ref": "#/components/responses/NoContent" }, "404": { "$ref": "#/components/responses/NotFound" }, "502": { "$ref": "#/components/responses/Error" } } } }, + "/v1/integrations/configured/{profileID}/groups": { + "parameters": [{ "$ref": "#/components/parameters/CredentialProfileID" }], + "get": { "operationId": "listIntegrationGroupGrants", "summary": "List the user groups granted an integration credential profile", "tags": ["Integrations"], "x-required-scope": "manage", "responses": { "200": { "description": "Groups granted this credential profile", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/IntegrationGroupGrant" } } } } }, "403": { "$ref": "#/components/responses/Forbidden" } } } + }, "/v1/snippets": { "get": { "operationId": "listSnippets", "summary": "List snippets", "tags": ["Snippets"], "responses": { "200": { "description": "Snippets", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Snippet" } } } } } } }, "post": { "operationId": "createSnippet", "summary": "Create a snippet", "tags": ["Snippets"], "x-required-scope": "manage", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateSnippetRequest" } } } }, "responses": { "201": { "description": "Snippet created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Snippet" } } } }, "400": { "$ref": "#/components/responses/BadRequest" } } } @@ -653,6 +675,8 @@ "TokenID": { "name": "tokenID", "in": "path", "required": true, "schema": { "type": "string" } }, "KeyID": { "name": "keyID", "in": "path", "required": true, "schema": { "type": "string" } }, "UserID": { "name": "userID", "in": "path", "required": true, "schema": { "type": "string" } }, + "GroupID": { "name": "groupID", "in": "path", "required": true, "schema": { "type": "string" } }, + "CredentialProfileID": { "name": "profileID", "in": "path", "required": true, "schema": { "type": "string" } }, "Search": { "name": "q", "in": "query", "description": "Case-insensitive search text.", "schema": { "type": "string" } }, "Limit100": { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }, "Offset": { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }, @@ -689,7 +713,7 @@ "RefreshRequest": { "type": "object", "properties": { "refresh_token": { "type": "string", "description": "May be omitted when the refresh-token cookie is present." } } }, "TokenPair": { "type": "object", "required": ["access_token", "refresh_token", "expires_at", "token_type"], "properties": { "access_token": { "type": "string" }, "refresh_token": { "type": "string" }, "expires_at": { "type": "string", "format": "date-time" }, "token_type": { "type": "string", "const": "Bearer" } } }, "SetActiveTenantRequest": { "type": "object", "required": ["slug"], "properties": { "slug": { "type": "string" } } }, - "OrgMembership": { "type": "object", "required": ["tenant_id", "slug", "name", "role"], "properties": { "tenant_id": { "type": "string" }, "slug": { "type": "string" }, "name": { "type": "string" }, "role": { "type": "string", "enum": ["invoke", "manage", "admin"] } } }, + "OrgMembership": { "type": "object", "required": ["tenant_id", "slug", "name", "role"], "properties": { "tenant_id": { "type": "string" }, "slug": { "type": "string" }, "name": { "type": "string" }, "role": { "type": "string", "enum": ["owner", "admin", "integration_manager", "manage", "member", "invoke", "viewer"] } } }, "JWK": { "type": "object", "required": ["kty", "use", "alg", "kid", "n", "e"], "properties": { "kty": { "type": "string", "const": "RSA" }, "use": { "type": "string", "const": "sig" }, "alg": { "type": "string", "const": "RS256" }, "kid": { "type": "string" }, "n": { "type": "string" }, "e": { "type": "string" } } }, "JWKS": { "type": "object", "required": ["keys"], "properties": { "keys": { "type": "array", "items": { "$ref": "#/components/schemas/JWK" } } } }, "Branding": { "type": "object", "properties": { "logo_url": { "type": "string", "format": "uri" }, "accent_color": { "type": "string" }, "font_family": { "type": "string" }, "custom_domain": { "type": "string" }, "hide_branding": { "type": "boolean" } } }, @@ -697,10 +721,16 @@ "RuntimeLimits": { "type": "object", "required": ["max_timeout_ms", "max_memory_mb", "max_cpu_percent"], "properties": { "max_timeout_ms": { "type": "integer" }, "max_memory_mb": { "type": "integer" }, "max_cpu_percent": { "type": "integer" } } }, "CreateAPIKeyRequest": { "type": "object", "required": ["name", "scopes"], "properties": { "name": { "type": "string" }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["invoke", "manage", "admin"] }, "uniqueItems": true } } }, "APIKey": { "type": "object", "required": ["id", "name", "scopes", "key_prefix", "created_at"], "properties": { "id": { "type": "string" }, "tenant_id": { "type": "string", "description": "Present in the create response." }, "name": { "type": "string" }, "scopes": { "type": "array", "items": { "type": "string" } }, "key_prefix": { "type": "string" }, "key": { "type": "string", "pattern": "^vl_", "description": "Present only in the create response." }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, "last_used_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } } }, - "TenantMember": { "type": "object", "required": ["tenant_id", "user_id", "email", "role"], "properties": { "tenant_id": { "type": "string" }, "user_id": { "type": "string" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["invoke", "manage", "admin"] }, "invited_at": { "type": "string", "format": "date-time" } } }, - "InviteMemberRequest": { "type": "object", "required": ["email"], "properties": { "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["invoke", "manage", "admin"], "default": "manage" } } }, + "TenantMember": { "type": "object", "required": ["tenant_id", "user_id", "email", "role"], "properties": { "tenant_id": { "type": "string" }, "user_id": { "type": "string" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["owner", "admin", "integration_manager", "manage", "member", "invoke", "viewer"] }, "invited_at": { "type": "string", "format": "date-time" } } }, + "InviteMemberRequest": { "type": "object", "required": ["email"], "properties": { "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["owner", "admin", "integration_manager", "manage", "member", "invoke", "viewer"], "default": "manage" } } }, + "UserGroup": { "type": "object", "properties": { "id": { "type": "string" }, "tenant_id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "members": { "type": "array", "items": { "$ref": "#/components/schemas/UserGroupMember" } }, "integration_grants": { "type": "array", "items": { "$ref": "#/components/schemas/IntegrationGroupGrant" } } } }, + "UserGroupMember": { "type": "object", "properties": { "group_id": { "type": "string" }, "user_id": { "type": "string" }, "email": { "type": "string" }, "added_at": { "type": "string", "format": "date-time" } } }, + "IntegrationGroupGrant": { "type": "object", "properties": { "group_id": { "type": "string" }, "group_name": { "type": "string" }, "credential_profile_id": { "type": "string" }, "granted_at": { "type": "string", "format": "date-time" } } }, + "CreateUserGroupRequest": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" }, "description": { "type": "string" } } }, + "UserGroupMemberRequest": { "type": "object", "description": "Identify the tenant member by id or email.", "properties": { "user_id": { "type": "string" }, "email": { "type": "string", "format": "email" } } }, + "IntegrationGrantRequest": { "type": "object", "required": ["credential_profile_id"], "properties": { "credential_profile_id": { "type": "string" } } }, "InviteMemberResponse": { "type": "object", "required": ["invite_token", "expires_at"], "properties": { "invite_token": { "type": "string" }, "expires_at": { "type": "string", "format": "date-time" } } }, - "Invite": { "type": "object", "required": ["id", "tenant_id", "email", "role", "expires_at", "created_at"], "properties": { "id": { "type": "string" }, "tenant_id": { "type": "string" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["invoke", "manage", "admin"] }, "expires_at": { "type": "string", "format": "date-time" }, "accepted_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } } }, + "Invite": { "type": "object", "required": ["id", "tenant_id", "email", "role", "expires_at", "created_at"], "properties": { "id": { "type": "string" }, "tenant_id": { "type": "string" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["owner", "admin", "integration_manager", "manage", "member", "invoke", "viewer"] }, "expires_at": { "type": "string", "format": "date-time" }, "accepted_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } } }, "TenantPlan": { "type": "object", "required": ["plan", "valid", "features"], "properties": { "plan": { "type": "string" }, "valid": { "type": "boolean" }, "features": { "type": "array", "items": { "type": "string" } } } }, "UsageSummary": { "type": "object", "required": ["tenant_id", "window", "total_invocations", "error_rate", "avg_duration_ms", "top_snippets"], "properties": { "tenant_id": { "type": "string" }, "window": { "type": "string" }, "total_invocations": { "type": "integer" }, "error_rate": { "type": "number" }, "avg_duration_ms": { "type": "number" }, "top_snippets": { "type": "array", "items": { "type": "object", "properties": { "snippet_id": { "type": "string" }, "name": { "type": "string" }, "invocations": { "type": "integer" }, "p95_ms": { "type": "number" } } } } } }, "AuditEntry": { "type": "object", "required": ["id", "tenant_id", "actor_id", "actor_type", "action", "created_at"], "properties": { "id": { "type": "string" }, "tenant_id": { "type": "string" }, "actor_id": { "type": "string" }, "actor_type": { "type": "string" }, "action": { "type": "string" }, "resource_id": { "type": "string" }, "metadata": { "type": "object", "additionalProperties": true }, "created_at": { "type": "string", "format": "date-time" } } }, diff --git a/services/control-plane/internal/api/router.go b/services/control-plane/internal/api/router.go index c4fbc1e..3405a77 100644 --- a/services/control-plane/internal/api/router.go +++ b/services/control-plane/internal/api/router.go @@ -12,6 +12,7 @@ import ( "github.com/abskrj/velane/services/control-plane/internal/auth/oauth" "github.com/abskrj/velane/services/control-plane/internal/hub" "github.com/abskrj/velane/services/control-plane/internal/license" + "github.com/abskrj/velane/services/control-plane/internal/models" "github.com/abskrj/velane/services/control-plane/internal/nango" "github.com/abskrj/velane/services/control-plane/internal/platformlibs" "github.com/abskrj/velane/services/control-plane/internal/sandboxcontrol" @@ -71,7 +72,7 @@ func newRouter(store *postgres.Store, sched *scheduler.Scheduler, log *zap.Logge metricsH := handlers.NewMetricsHandler(store, log) replayH := handlers.NewReplayHandler(store, sched, log) embedH := handlers.NewEmbedHandler(store, log) - connectionsH := handlers.NewConnectionsHandler(store, nangoClient, log, nangoConnectURL, nangoApiURL).WithAuditor(auditor) + connectionsH := handlers.NewConnectionsHandler(store, nangoClient, log, nangoConnectURL, nangoApiURL).WithAuditor(auditor).WithCallerKey(encKey) integrationsH := handlers.NewIntegrationsHandler(nangoClient, log, nangoInternalURL, nangoApiURL, mcpPublicURL) configureIntH := handlers.NewConfigureIntegrationsHandler(store, nangoClient, log, encKey) triggersH := handlers.NewWorkflowTriggersHandler(store, nangoClient, log) @@ -81,6 +82,7 @@ func newRouter(store *postgres.Store, sched *scheduler.Scheduler, log *zap.Logge } brandingH := handlers.NewBrandingHandler(store, log).WithAuditor(auditor) membersH := handlers.NewMembersHandler(store, log).WithAuditor(auditor) + userGroupsH := handlers.NewUserGroupsHandler(store, log).WithAuditor(auditor) usageH := handlers.NewUsageHandler(store, log) apikeysH := handlers.NewAPIKeysHandler(store, log).WithAuditor(auditor) auditH := handlers.NewAuditHandler(store, log) @@ -215,6 +217,27 @@ func newRouter(store *postgres.Store, sched *scheduler.Scheduler, log *zap.Logge r.With(middleware.RequireScope("admin", log)). Get("/v1/tenant/members/invites", membersH.ListInvites) + // User groups. Membership is an admin/owner concern; granting an + // integration to a group is additionally open to integration managers. + adminOrOwner := middleware.RequireRole(log, models.RoleAdmin, models.RoleOwner) + integrationGranters := middleware.RequireRole(log, models.RoleAdmin, models.RoleOwner, models.RoleIntegrationManager) + r.With(middleware.RequireScope("manage", log), integrationGranters). + Get("/v1/tenant/groups", userGroupsH.ListGroups) + r.With(middleware.RequireScope("admin", log), adminOrOwner). + Post("/v1/tenant/groups", userGroupsH.CreateGroup) + r.With(middleware.RequireScope("admin", log), adminOrOwner). + Delete("/v1/tenant/groups/{groupID}", userGroupsH.DeleteGroup) + r.With(middleware.RequireScope("admin", log), adminOrOwner). + Post("/v1/tenant/groups/{groupID}/members", userGroupsH.AddMember) + r.With(middleware.RequireScope("admin", log), adminOrOwner). + Delete("/v1/tenant/groups/{groupID}/members", userGroupsH.RemoveMember) + r.With(middleware.RequireScope("manage", log), integrationGranters). + Post("/v1/tenant/groups/{groupID}/integrations", userGroupsH.GrantIntegration) + r.With(middleware.RequireScope("manage", log), integrationGranters). + Delete("/v1/tenant/groups/{groupID}/integrations", userGroupsH.RevokeIntegration) + r.With(middleware.RequireScope("manage", log), integrationGranters). + Get("/v1/integrations/configured/{profileID}/groups", userGroupsH.ListIntegrationGrants) + // Plan / billing (cloud mode only — returns free plan when VELANE_CLOUD is not set). r.Get("/v1/tenant/plan", billingH.GetPlan) diff --git a/services/control-plane/internal/api/user_groups_test.go b/services/control-plane/internal/api/user_groups_test.go new file mode 100644 index 0000000..3b51256 --- /dev/null +++ b/services/control-plane/internal/api/user_groups_test.go @@ -0,0 +1,269 @@ +// Package api_test — integration tests for user groups and group-based +// integration access control. These tests require TEST_DATABASE_URL. +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/abskrj/velane/services/control-plane/internal/callerid" + "github.com/abskrj/velane/services/control-plane/internal/models" +) + +// decodeInto decodes a recorded JSON response into out. +func decodeInto(t *testing.T, rec *httptest.ResponseRecorder, out any) { + t.Helper() + if err := json.NewDecoder(rec.Body).Decode(out); err != nil { + t.Fatalf("decode JSON body (status %d): %v", rec.Code, err) + } +} + +// groupUser creates a tenant member with the given role and an API key owned by +// that user, returning the user ID and the plain key. +func groupUser(t *testing.T, env *testEnv, role string, scopes ...string) (string, string) { + t.Helper() + ctx := context.Background() + + email := fmt.Sprintf("user-%d@example.com", time.Now().UnixNano()) + user, err := env.store.CreateUserNoPassword(ctx, email) + if err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := env.store.AddMember(ctx, env.tenant.ID, user.ID, role); err != nil { + t.Fatalf("add member: %v", err) + } + key, plain, err := env.store.CreateAPIKeyWithPlainForUser(ctx, env.tenant.ID, "owned-key", scopes, &user.ID) + if err != nil { + t.Fatalf("create owned api key: %v", err) + } + if key.UserID == nil || *key.UserID != user.ID { + t.Fatalf("api key must record its owner") + } + return user.ID, plain +} + +func createGroup(t *testing.T, env *testEnv, name string) string { + t.Helper() + rec := env.do(t, http.MethodPost, "/v1/tenant/groups", env.manageKey, map[string]any{ + "name": name, + "description": "test group", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("create group: status=%d body=%s", rec.Code, rec.Body.String()) + } + group := decodeJSON(t, rec) + id, _ := group["id"].(string) + if id == "" { + t.Fatalf("group id missing in response: %s", rec.Body.String()) + } + return id +} + +func TestUserGroups_CRUDRequiresAdminRole(t *testing.T) { + env := setupWithNango(t) + + groupID := createGroup(t, env, fmt.Sprintf("support-%d", time.Now().UnixNano())) + + rec := env.do(t, http.MethodGet, "/v1/tenant/groups", env.manageKey, nil) + if rec.Code != http.StatusOK { + t.Fatalf("list groups: status=%d body=%s", rec.Code, rec.Body.String()) + } + + // invoke-only keys map to the member role and must not manage groups. + rec = env.do(t, http.MethodPost, "/v1/tenant/groups", env.invokeKey, map[string]any{"name": "nope"}) + if rec.Code != http.StatusForbidden { + t.Errorf("create group with invoke key: status=%d; want 403", rec.Code) + } + + rec = env.do(t, http.MethodDelete, "/v1/tenant/groups/"+groupID, env.manageKey, nil) + if rec.Code != http.StatusNoContent { + t.Errorf("delete group: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUserGroups_MembersAndGrants(t *testing.T) { + env := setupWithNango(t) + userID, _ := groupUser(t, env, models.RoleMember, "invoke") + profile := configureProfile(t, env, "github", fmt.Sprintf("granted-%d", time.Now().UnixNano()), false) + groupID := createGroup(t, env, fmt.Sprintf("granted-group-%d", time.Now().UnixNano())) + + rec := env.do(t, http.MethodPost, "/v1/tenant/groups/"+groupID+"/members", env.manageKey, + map[string]any{"user_id": userID}) + if rec.Code != http.StatusCreated { + t.Fatalf("add member: status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = env.do(t, http.MethodPost, "/v1/tenant/groups/"+groupID+"/integrations", env.manageKey, + map[string]any{"credential_profile_id": profile["id"]}) + if rec.Code != http.StatusCreated { + t.Fatalf("grant integration: status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = env.do(t, http.MethodGet, "/v1/tenant/groups", env.manageKey, nil) + if rec.Code != http.StatusOK { + t.Fatalf("list groups: status=%d body=%s", rec.Code, rec.Body.String()) + } + var groups []struct { + ID string `json:"id"` + Members []struct { + UserID string `json:"user_id"` + } `json:"members"` + Grants []struct { + CredentialProfileID string `json:"credential_profile_id"` + } `json:"integration_grants"` + } + decodeInto(t, rec, &groups) + var found bool + for _, g := range groups { + if g.ID != groupID { + continue + } + found = true + if len(g.Members) != 1 || g.Members[0].UserID != userID { + t.Errorf("members = %+v; want the added user", g.Members) + } + if len(g.Grants) != 1 || g.Grants[0].CredentialProfileID != profile["id"] { + t.Errorf("grants = %+v; want the granted profile", g.Grants) + } + } + if !found { + t.Fatalf("created group missing from list") + } + + rec = env.do(t, http.MethodDelete, "/v1/tenant/groups/"+groupID+"/integrations", env.manageKey, + map[string]any{"credential_profile_id": profile["id"]}) + if rec.Code != http.StatusNoContent { + t.Errorf("revoke integration: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUserGroups_ListConfiguredFiltersRestrictedProfiles(t *testing.T) { + env := setupWithNango(t) + suffix := time.Now().UnixNano() + open := configureProfile(t, env, "github", fmt.Sprintf("open-%d", suffix), false) + restricted := configureProfile(t, env, "github", fmt.Sprintf("restricted-%d", suffix), false) + + grantedUser, grantedKey := groupUser(t, env, models.RoleMember, "invoke", "manage") + _, otherKey := groupUser(t, env, models.RoleMember, "invoke", "manage") + + groupID := createGroup(t, env, fmt.Sprintf("filter-group-%d", suffix)) + if rec := env.do(t, http.MethodPost, "/v1/tenant/groups/"+groupID+"/members", env.manageKey, + map[string]any{"user_id": grantedUser}); rec.Code != http.StatusCreated { + t.Fatalf("add member: status=%d body=%s", rec.Code, rec.Body.String()) + } + if rec := env.do(t, http.MethodPost, "/v1/tenant/groups/"+groupID+"/integrations", env.manageKey, + map[string]any{"credential_profile_id": restricted["id"]}); rec.Code != http.StatusCreated { + t.Fatalf("grant integration: status=%d body=%s", rec.Code, rec.Body.String()) + } + + listed := func(key string) map[string]bool { + rec := env.do(t, http.MethodGet, "/v1/integrations/configured?status=all", key, nil) + if rec.Code != http.StatusOK { + t.Fatalf("list configured: status=%d body=%s", rec.Code, rec.Body.String()) + } + var profiles []struct { + ID string `json:"id"` + } + decodeInto(t, rec, &profiles) + out := map[string]bool{} + for _, p := range profiles { + out[p.ID] = true + } + return out + } + + adminView := listed(env.manageKey) + if !adminView[restricted["id"].(string)] { + t.Error("admin must see restricted profiles") + } + + grantedView := listed(grantedKey) + if !grantedView[restricted["id"].(string)] { + t.Error("granted group member must see the restricted profile") + } + if !grantedView[open["id"].(string)] { + t.Error("unrestricted profiles stay visible to every member") + } + + otherView := listed(otherKey) + if otherView[restricted["id"].(string)] { + t.Error("member outside the granted group must not see the restricted profile") + } + if !otherView[open["id"].(string)] { + t.Error("unrestricted profiles stay visible to members outside the group") + } +} + +func TestProxy_EnforcesGroupGrants(t *testing.T) { + env := setupWithNango(t) + suffix := time.Now().UnixNano() + profile := configureProfile(t, env, "github", "default", true) + + if rec := env.do(t, http.MethodPost, "/v1/tenant/connections", env.manageKey, map[string]any{ + "provider": "github", + "credential_profile_id": profile["id"], + }); rec.Code != http.StatusCreated { + t.Fatalf("record connection: status=%d body=%s", rec.Code, rec.Body.String()) + } + + groupID := createGroup(t, env, fmt.Sprintf("proxy-group-%d", suffix)) + if rec := env.do(t, http.MethodPost, "/v1/tenant/groups/"+groupID+"/integrations", env.manageKey, + map[string]any{"credential_profile_id": profile["id"]}); rec.Code != http.StatusCreated { + t.Fatalf("grant integration: status=%d body=%s", rec.Code, rec.Body.String()) + } + + proxy := func(callerToken string) int { + req := httptest.NewRequest(http.MethodGet, "/v1/proxy/github/user", nil) + req.Header.Set("X-Velane-Tenant", env.tenant.ID) + if callerToken != "" { + req.Header.Set(callerid.Header, callerToken) + } + rec := httptest.NewRecorder() + env.router.ServeHTTP(rec, req) + return rec.Code + } + + sign := func(claims callerid.Claims) string { + claims.TenantID = env.tenant.ID + token, err := callerid.Sign(testEncKey, claims, time.Hour) + if err != nil { + t.Fatalf("sign caller token: %v", err) + } + return token + } + + if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { + t.Errorf("granted member proxy status = %d; want 200", code) + } + if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{"unrelated"}})); code != http.StatusForbidden { + t.Errorf("ungranted member proxy status = %d; want 403", code) + } + if code := proxy(sign(callerid.Claims{Role: models.RoleAdmin})); code != http.StatusOK { + t.Errorf("admin proxy status = %d; want 200", code) + } + + // A forged token is not signed with the control plane key and is ignored; + // with strict mode off the caller falls back to legacy tenant-wide access. + if code := proxy("not-a-real-token"); code != http.StatusOK { + t.Errorf("legacy fallback proxy status = %d; want 200", code) + } + if code := proxy(""); code != http.StatusOK { + t.Errorf("tokenless proxy status = %d; want 200", code) + } + + // With strict mode enabled, unidentified callers are denied. + if err := env.store.SetTenantRBACStrictMode(context.Background(), env.tenant.ID, true); err != nil { + t.Fatalf("SetTenantRBACStrictMode: %v", err) + } + if code := proxy(""); code != http.StatusForbidden { + t.Errorf("strict-mode tokenless proxy status = %d; want 403", code) + } + if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { + t.Errorf("strict-mode granted member proxy status = %d; want 200", code) + } +} diff --git a/services/control-plane/internal/callerid/callerid.go b/services/control-plane/internal/callerid/callerid.go new file mode 100644 index 0000000..d23f0f8 --- /dev/null +++ b/services/control-plane/internal/callerid/callerid.go @@ -0,0 +1,92 @@ +// Package callerid mints and verifies the short-lived, integrity-protected +// token that carries the invoking principal's identity into a sandbox. +// +// The /v1/proxy trust boundary is the X-Velane-Tenant header, so snippet code +// could otherwise claim any group membership. The token is signed with the +// control plane's encryption key and verified on every proxy call, which makes +// the caller's groups unforgeable from inside the sandbox. +package callerid + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// EnvVar is the sandbox environment variable holding the signed token. +const EnvVar = "VELANE_CALLER_TOKEN" + +// Header is the HTTP header the platform libraries forward it on. +const Header = "X-Velane-Caller" + +// DefaultTTL bounds how long a minted token stays usable. It must outlive the +// longest invocation but stays short so a leaked token expires quickly. +const DefaultTTL = time.Hour + +// Claims is the caller identity carried by the token. +type Claims struct { + TenantID string `json:"tid"` + UserID string `json:"uid,omitempty"` + Role string `json:"role,omitempty"` + GroupIDs []string `json:"groups,omitempty"` + ExpiresAt int64 `json:"exp"` +} + +// Sign encodes and signs the claims with an expiry of now+ttl. +func Sign(key []byte, claims Claims, ttl time.Duration) (string, error) { + if len(key) == 0 { + return "", fmt.Errorf("callerid: signing key is empty") + } + if ttl <= 0 { + ttl = DefaultTTL + } + claims.ExpiresAt = time.Now().Add(ttl).Unix() + + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("callerid: marshal claims: %w", err) + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + return encoded + "." + base64.RawURLEncoding.EncodeToString(sign(key, encoded)), nil +} + +// Verify checks the signature and expiry and returns the claims. +func Verify(key []byte, token string) (*Claims, error) { + if len(key) == 0 { + return nil, fmt.Errorf("callerid: verification key is empty") + } + encoded, sig, ok := strings.Cut(token, ".") + if !ok || encoded == "" || sig == "" { + return nil, fmt.Errorf("callerid: malformed token") + } + got, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil { + return nil, fmt.Errorf("callerid: malformed signature") + } + if !hmac.Equal(got, sign(key, encoded)) { + return nil, fmt.Errorf("callerid: signature mismatch") + } + + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("callerid: malformed payload") + } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("callerid: decode claims: %w", err) + } + if claims.ExpiresAt > 0 && time.Now().After(time.Unix(claims.ExpiresAt, 0)) { + return nil, fmt.Errorf("callerid: token has expired") + } + return &claims, nil +} + +func sign(key []byte, encoded string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(encoded)) + return mac.Sum(nil) +} diff --git a/services/control-plane/internal/callerid/callerid_test.go b/services/control-plane/internal/callerid/callerid_test.go new file mode 100644 index 0000000..f3006d0 --- /dev/null +++ b/services/control-plane/internal/callerid/callerid_test.go @@ -0,0 +1,85 @@ +package callerid_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/abskrj/velane/services/control-plane/internal/callerid" +) + +var key = []byte("0123456789abcdef0123456789abcdef") + +func TestSignVerifyRoundTrip(t *testing.T) { + token, err := callerid.Sign(key, callerid.Claims{ + TenantID: "tenant-1", + UserID: "user-1", + Role: "member", + GroupIDs: []string{"group-1", "group-2"}, + }, time.Minute) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + claims, err := callerid.Verify(key, token) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if claims.TenantID != "tenant-1" || claims.UserID != "user-1" || claims.Role != "member" { + t.Errorf("claims = %+v; want the signed identity", claims) + } + if len(claims.GroupIDs) != 2 || claims.GroupIDs[0] != "group-1" { + t.Errorf("GroupIDs = %v; want the signed groups", claims.GroupIDs) + } + if claims.ExpiresAt <= time.Now().Unix() { + t.Errorf("ExpiresAt = %d; want a future expiry", claims.ExpiresAt) + } +} + +func TestVerifyRejectsTamperedAndForeignTokens(t *testing.T) { + token, err := callerid.Sign(key, callerid.Claims{TenantID: "tenant-1", GroupIDs: []string{"group-1"}}, time.Minute) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + forged, err := callerid.Sign([]byte("ffffffffffffffffffffffffffffffff"), callerid.Claims{ + TenantID: "tenant-1", + GroupIDs: []string{"group-1"}, + }, time.Minute) + if err != nil { + t.Fatalf("Sign with other key: %v", err) + } + + cases := map[string]string{ + "empty": "", + "malformed": "not-a-token", + "tampered payload": "eyJ0aWQiOiJ0ZW5hbnQtMSJ9." + token[len(token)-43:], + "foreign key": forged, + } + for name, tok := range cases { + if _, err := callerid.Verify(key, tok); err == nil { + t.Errorf("Verify(%s) = nil error; want rejection", name) + } + } +} + +func TestVerifyRejectsExpiredToken(t *testing.T) { + payload, err := json.Marshal(callerid.Claims{ + TenantID: "tenant-1", + ExpiresAt: time.Now().Add(-time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, key) + mac.Write([]byte(encoded)) + token := encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + if _, err := callerid.Verify(key, token); err == nil { + t.Error("Verify(expired) = nil error; want rejection") + } +} diff --git a/services/control-plane/internal/models/apikey.go b/services/control-plane/internal/models/apikey.go index 399e87e..b0a5c17 100644 --- a/services/control-plane/internal/models/apikey.go +++ b/services/control-plane/internal/models/apikey.go @@ -9,7 +9,8 @@ type APIKey struct { KeyHash string `json:"-"` // bcrypt hash — never serialised KeyPrefix string `json:"key_prefix"` // first 8 chars after "vl_" prefix Name string `json:"name"` - Scopes []string `json:"scopes"` // "invoke", "manage", "admin" + Scopes []string `json:"scopes"` // "invoke", "manage", "admin" + UserID *string `json:"user_id,omitempty"` // creating user; nil = legacy tenant-wide key ExpiresAt *time.Time `json:"expires_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/services/control-plane/internal/models/user_group.go b/services/control-plane/internal/models/user_group.go new file mode 100644 index 0000000..7152d90 --- /dev/null +++ b/services/control-plane/internal/models/user_group.go @@ -0,0 +1,98 @@ +package models + +import "time" + +// Tenant member roles. The legacy invoke/manage/admin values keep working and +// the RBAC roles below are layered on top of them. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleIntegrationManager = "integration_manager" + RoleManage = "manage" + RoleMember = "member" + RoleInvoke = "invoke" + RoleViewer = "viewer" +) + +// ValidRoles lists every role accepted on invites and membership rows. +var ValidRoles = []string{RoleInvoke, RoleManage, RoleAdmin, RoleOwner, RoleIntegrationManager, RoleMember, RoleViewer} + +// IsValidRole reports whether role is one of ValidRoles. +func IsValidRole(role string) bool { + for _, r := range ValidRoles { + if r == role { + return true + } + } + return false +} + +// RoleGrantsScope maps a tenant member role onto the API key scope model. +// owner/admin grant every scope, integration_manager mirrors manage, member +// mirrors invoke, and viewer is read-only so it grants none. +func RoleGrantsScope(role, scope string) bool { + switch role { + case RoleAdmin, RoleOwner: + return true + case RoleManage, RoleIntegrationManager: + return scope == RoleInvoke || scope == RoleManage + case RoleInvoke, RoleMember: + return scope == RoleInvoke + } + return false +} + +// RoleSeesAllIntegrations reports whether a role bypasses group-based +// integration filtering and enforcement. +func RoleSeesAllIntegrations(role string) bool { + return role == RoleOwner || role == RoleAdmin +} + +// UserGroup is a tenant-scoped collection of users used to grant integration access. +type UserGroup struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserGroupMember links a user to a group. +type UserGroupMember struct { + GroupID string `json:"group_id"` + UserID string `json:"user_id"` + Email string `json:"email,omitempty"` + AddedAt time.Time `json:"added_at"` +} + +// IntegrationGroupGrant grants a group access to one integration credential profile. +type IntegrationGroupGrant struct { + GroupID string `json:"group_id"` + GroupName string `json:"group_name,omitempty"` + CredentialProfileID string `json:"credential_profile_id"` + GrantedAt time.Time `json:"granted_at"` +} + +// CallerIdentity is the resolved principal behind a request or an invocation: +// the tenant it acts in, the user it belongs to (empty for legacy tenant-wide +// API keys), its role and the groups it inherits integration access from. +type CallerIdentity struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id,omitempty"` + Role string `json:"role,omitempty"` + GroupIDs []string `json:"group_ids,omitempty"` +} + +// SeesAllIntegrations reports whether the caller bypasses group filtering. +func (c CallerIdentity) SeesAllIntegrations() bool { return RoleSeesAllIntegrations(c.Role) } + +// HasGroup reports whether the caller belongs to the given group. +func (c CallerIdentity) HasGroup(groupID string) bool { + for _, g := range c.GroupIDs { + if g == groupID { + return true + } + } + return false +} diff --git a/services/control-plane/internal/scheduler/scheduler.go b/services/control-plane/internal/scheduler/scheduler.go index 18eff4d..1d0e1d9 100644 --- a/services/control-plane/internal/scheduler/scheduler.go +++ b/services/control-plane/internal/scheduler/scheduler.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "math/rand" + "strings" "time" + "github.com/abskrj/velane/services/control-plane/internal/callerid" "github.com/abskrj/velane/services/control-plane/internal/executor" "github.com/abskrj/velane/services/control-plane/internal/models" "github.com/abskrj/velane/services/control-plane/internal/observability" @@ -46,10 +48,11 @@ type Queue interface { type InvokeRequest struct { TenantID string SnippetSlug string - Env string // "dev" | "staging" | "prod" - Input string // raw JSON - PinnedVersion int // 0 = use active version from environment - InvokeMode string // "sync" | "stream"; used when sync runs through the queue + Env string // "dev" | "staging" | "prod" + Input string // raw JSON + PinnedVersion int // 0 = use active version from environment + Caller models.CallerIdentity // principal whose group access the sandbox inherits + InvokeMode string // "sync" | "stream"; used when sync runs through the queue } // Scheduler resolves, executes, and records snippet invocations. @@ -136,11 +139,35 @@ func (s *Scheduler) getLibraries(ctx context.Context, tenantID, tenantSlug, lang // injectProxyEnv adds VELANE_PROXY_URL and VELANE_TENANT_ID to the env map so // that @velane/integrations inside snippet code can reach the internal proxy. -func (s *Scheduler) injectProxyEnv(env map[string]string, tenantID string) { +// +// It also mints the caller identity token the proxy uses for group-based +// integration checks. The group list is exposed unsigned for introspection +// only; the proxy trusts the signed token exclusively, so snippet code cannot +// grant itself access by editing either value. +func (s *Scheduler) injectProxyEnv(env map[string]string, tenantID string, caller models.CallerIdentity) { if s.internalProxyURL != "" { env["VELANE_PROXY_URL"] = s.internalProxyURL } env["VELANE_TENANT_ID"] = tenantID + + caller.TenantID = tenantID + if len(caller.GroupIDs) > 0 { + env["VELANE_CALLER_GROUPS"] = strings.Join(caller.GroupIDs, ",") + } + if caller.UserID == "" && caller.Role == "" && len(caller.GroupIDs) == 0 { + // Nothing to attest — legacy tenant-wide invocation. + return + } + token, err := callerid.Sign(s.encKey, callerid.Claims{ + TenantID: caller.TenantID, + UserID: caller.UserID, + Role: caller.Role, + GroupIDs: caller.GroupIDs, + }, callerid.DefaultTTL) + if err != nil { + return + } + env[callerid.EnvVar] = token } // SetObserver injects a post-invocation observer for observability pipelines. @@ -276,7 +303,7 @@ func (s *Scheduler) Invoke(ctx context.Context, req InvokeRequest) (*models.Invo return nil, fmt.Errorf("create invocation: %w", err) } - s.injectProxyEnv(secrets, req.TenantID) + s.injectProxyEnv(secrets, req.TenantID, req.Caller) result := s.executor.Run(ctx, executor.RunSpec{ Language: string(snippet.Language), Code: version.Code, @@ -340,7 +367,7 @@ func (s *Scheduler) InvokeAsync(ctx context.Context, req InvokeRequest, callback if err != nil { return nil, fmt.Errorf("fetch secrets: %w", err) } - s.injectProxyEnv(secrets, req.TenantID) + s.injectProxyEnv(secrets, req.TenantID, req.Caller) tenant, err := s.store.GetTenantByID(ctx, req.TenantID) if err != nil { @@ -445,7 +472,7 @@ func (s *Scheduler) InvokeQueued(ctx context.Context, req InvokeRequest) (*model // Inject the internal proxy env so @velane/integrations works inside the // snippet, matching the inline sync path. - s.injectProxyEnv(secrets, req.TenantID) + s.injectProxyEnv(secrets, req.TenantID, req.Caller) var jobEgress *redisstore.EgressPolicyJob if ep := tenant.EgressPolicy; len(ep.BlockedCIDRs) > 0 || len(ep.BlockedDomains) > 0 { @@ -514,7 +541,7 @@ func (s *Scheduler) InvokeStream(ctx context.Context, req InvokeRequest) (<-chan if err != nil { return nil, nil, fmt.Errorf("create invocation: %w", err) } - s.injectProxyEnv(secrets, req.TenantID) + s.injectProxyEnv(secrets, req.TenantID, req.Caller) streamLibs, err := s.getLibraries(ctx, req.TenantID, tenant.Slug, string(snippet.Language)) if err != nil { diff --git a/services/control-plane/internal/store/postgres/apikeys.go b/services/control-plane/internal/store/postgres/apikeys.go index 679c007..1572a71 100644 --- a/services/control-plane/internal/store/postgres/apikeys.go +++ b/services/control-plane/internal/store/postgres/apikeys.go @@ -29,8 +29,15 @@ func generateRawKey() (plain, prefix string, err error) { // CreateAPIKeyWithPlain generates a key, hashes it, persists the record, and // returns both the model and the one-time plain-text key. The plain key is -// never stored — surface it to the caller immediately. +// never stored — surface it to the caller immediately. The key is created +// without an owning user, so it carries tenant-wide access. func (s *Store) CreateAPIKeyWithPlain(ctx context.Context, tenantID, name string, scopes []string) (*models.APIKey, string, error) { + return s.CreateAPIKeyWithPlainForUser(ctx, tenantID, name, scopes, nil) +} + +// CreateAPIKeyWithPlainForUser creates an API key owned by userID. The key +// inherits that user's group access when integrations are checked at runtime. +func (s *Store) CreateAPIKeyWithPlainForUser(ctx context.Context, tenantID, name string, scopes []string, userID *string) (*models.APIKey, string, error) { plain, prefix, err := generateRawKey() if err != nil { return nil, "", err @@ -42,10 +49,10 @@ func (s *Store) CreateAPIKeyWithPlain(ctx context.Context, tenantID, name string } row := s.pool.QueryRow(ctx, - `INSERT INTO api_keys (id, tenant_id, key_hash, key_prefix, name, scopes) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, tenant_id, key_hash, key_prefix, name, scopes, expires_at, last_used_at, created_at`, - ids.New(), tenantID, string(hash), prefix, name, scopes, + `INSERT INTO api_keys (id, tenant_id, key_hash, key_prefix, name, scopes, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, tenant_id, key_hash, key_prefix, name, scopes, user_id, expires_at, last_used_at, created_at`, + ids.New(), tenantID, string(hash), prefix, name, scopes, userID, ) k, err := scanAPIKey(row) @@ -65,7 +72,7 @@ func (s *Store) ValidateAPIKey(ctx context.Context, plain string) (*models.APIKe prefix := plain[3:11] rows, err := s.pool.Query(ctx, - `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, expires_at, last_used_at, created_at + `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, user_id, expires_at, last_used_at, created_at FROM api_keys WHERE key_prefix = $1`, prefix, @@ -108,7 +115,7 @@ func (s *Store) ValidateAPIKey(ctx context.Context, plain string) (*models.APIKe // GetAPIKey retrieves a key record by its primary key. func (s *Store) GetAPIKey(ctx context.Context, id string) (*models.APIKey, error) { row := s.pool.QueryRow(ctx, - `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, expires_at, last_used_at, created_at + `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, user_id, expires_at, last_used_at, created_at FROM api_keys WHERE id = $1`, id, ) @@ -122,7 +129,7 @@ func (s *Store) GetAPIKey(ctx context.Context, id string) (*models.APIKey, error // ListAPIKeys returns all API keys for a given tenant. func (s *Store) ListAPIKeys(ctx context.Context, tenantID string) ([]*models.APIKey, error) { rows, err := s.pool.Query(ctx, - `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, expires_at, last_used_at, created_at + `SELECT id, tenant_id, key_hash, key_prefix, name, scopes, user_id, expires_at, last_used_at, created_at FROM api_keys WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID, @@ -176,7 +183,7 @@ func scanAPIKey(s scannable) (*models.APIKey, error) { var k models.APIKey if err := s.Scan( &k.ID, &k.TenantID, &k.KeyHash, &k.KeyPrefix, - &k.Name, &k.Scopes, &k.ExpiresAt, &k.LastUsedAt, &k.CreatedAt, + &k.Name, &k.Scopes, &k.UserID, &k.ExpiresAt, &k.LastUsedAt, &k.CreatedAt, ); err != nil { return nil, err } diff --git a/services/control-plane/internal/store/postgres/db.go b/services/control-plane/internal/store/postgres/db.go index 87d50f8..e030c2c 100644 --- a/services/control-plane/internal/store/postgres/db.go +++ b/services/control-plane/internal/store/postgres/db.go @@ -95,6 +95,9 @@ var migrationSQL27 string //go:embed migrations/028_sandbox_lifecycle_artifact_manifest.sql var migrationSQL28 string +//go:embed migrations/029_rbac_user_groups.sql +var migrationSQL29 string + // Store wraps a pgxpool.Pool and provides all database operations. type Store struct { pool *pgxpool.Pool @@ -146,7 +149,7 @@ func New(ctx context.Context, dsn string) (*Store, error) { return nil, fmt.Errorf("postgres ping: %w", err) } - for i, sql := range []string{migrationSQL1, migrationSQL2, migrationSQL3, migrationSQL4, migrationSQL5, migrationSQL6, migrationSQL7, migrationSQL8, migrationSQL9, migrationSQL10, migrationSQL11, migrationSQL12, migrationSQL13, migrationSQL14, migrationSQL15, migrationSQL16, migrationSQL17, migrationSQL18, migrationSQL19, migrationSQL20, migrationSQL21, migrationSQL22, migrationSQL23, migrationSQL24, migrationSQL25, migrationSQL26, migrationSQL27, migrationSQL28} { + for i, sql := range []string{migrationSQL1, migrationSQL2, migrationSQL3, migrationSQL4, migrationSQL5, migrationSQL6, migrationSQL7, migrationSQL8, migrationSQL9, migrationSQL10, migrationSQL11, migrationSQL12, migrationSQL13, migrationSQL14, migrationSQL15, migrationSQL16, migrationSQL17, migrationSQL18, migrationSQL19, migrationSQL20, migrationSQL21, migrationSQL22, migrationSQL23, migrationSQL24, migrationSQL25, migrationSQL26, migrationSQL27, migrationSQL28, migrationSQL29} { if _, err := pool.Exec(ctx, sql); err != nil { pool.Close() return nil, fmt.Errorf("running migration %d: %w", i+1, err) diff --git a/services/control-plane/internal/store/postgres/migrations/029_rbac_user_groups.sql b/services/control-plane/internal/store/postgres/migrations/029_rbac_user_groups.sql new file mode 100644 index 0000000..99e1859 --- /dev/null +++ b/services/control-plane/internal/store/postgres/migrations/029_rbac_user_groups.sql @@ -0,0 +1,55 @@ +-- Role-based access control: user groups, integration grants and caller-aware API keys. + +CREATE TABLE IF NOT EXISTS user_groups ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_user_groups_tenant_id ON user_groups(tenant_id); + +CREATE TABLE IF NOT EXISTS user_group_members ( + group_id TEXT NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_group_members_user_id ON user_group_members(user_id); + +CREATE TABLE IF NOT EXISTS integration_group_grants ( + group_id TEXT NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE, + credential_profile_id TEXT NOT NULL REFERENCES integration_credential_profiles(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, credential_profile_id) +); + +CREATE INDEX IF NOT EXISTS idx_integration_group_grants_profile + ON integration_group_grants(credential_profile_id); + +-- API keys inherit the group access of the user that created them. Existing keys +-- stay NULL, which means "tenant-wide" and keeps pre-RBAC deployments working. +ALTER TABLE api_keys + ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); + +-- When strict mode is on, callers without a resolvable user (legacy tenant-wide +-- API keys) lose access to integrations instead of seeing every one of them. +ALTER TABLE tenants + ADD COLUMN IF NOT EXISTS rbac_strict_mode BOOLEAN NOT NULL DEFAULT FALSE; + +-- Widen the member role set. admin/manage/invoke keep working unchanged. +ALTER TABLE tenant_members DROP CONSTRAINT IF EXISTS tenant_members_role_check; +ALTER TABLE tenant_members + ADD CONSTRAINT tenant_members_role_check + CHECK (role IN ('invoke', 'manage', 'admin', 'owner', 'integration_manager', 'member', 'viewer')); + +ALTER TABLE invite_tokens DROP CONSTRAINT IF EXISTS invite_tokens_role_check; +ALTER TABLE invite_tokens + ADD CONSTRAINT invite_tokens_role_check + CHECK (role IN ('invoke', 'manage', 'admin', 'owner', 'integration_manager', 'member', 'viewer')); diff --git a/services/control-plane/internal/store/postgres/user_groups.go b/services/control-plane/internal/store/postgres/user_groups.go new file mode 100644 index 0000000..ab957a3 --- /dev/null +++ b/services/control-plane/internal/store/postgres/user_groups.go @@ -0,0 +1,381 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/abskrj/velane/services/control-plane/internal/ids" + "github.com/abskrj/velane/services/control-plane/internal/models" +) + +// CreateUserGroup inserts a tenant-scoped group. Group names are unique per tenant. +func (s *Store) CreateUserGroup(ctx context.Context, tenantID, name, description string) (*models.UserGroup, error) { + row := s.pool.QueryRow(ctx, + `INSERT INTO user_groups (id, tenant_id, name, description) + VALUES ($1, $2, $3, $4) + RETURNING id, tenant_id, name, description, created_at, updated_at`, + ids.New(), tenantID, name, description, + ) + g, err := scanUserGroup(row) + if err != nil { + return nil, fmt.Errorf("CreateUserGroup: %w", err) + } + return g, nil +} + +// GetUserGroup returns a single group scoped to its tenant. +func (s *Store) GetUserGroup(ctx context.Context, tenantID, groupID string) (*models.UserGroup, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, name, description, created_at, updated_at + FROM user_groups WHERE tenant_id = $1 AND id = $2`, + tenantID, groupID, + ) + g, err := scanUserGroup(row) + if err != nil { + return nil, fmt.Errorf("GetUserGroup: %w", err) + } + return g, nil +} + +// ListUserGroups returns all groups for a tenant, oldest first. +func (s *Store) ListUserGroups(ctx context.Context, tenantID string) ([]*models.UserGroup, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, tenant_id, name, description, created_at, updated_at + FROM user_groups WHERE tenant_id = $1 ORDER BY created_at ASC`, + tenantID, + ) + if err != nil { + return nil, fmt.Errorf("ListUserGroups query: %w", err) + } + defer rows.Close() + + groups := []*models.UserGroup{} + for rows.Next() { + g, err := scanUserGroup(rows) + if err != nil { + return nil, fmt.Errorf("ListUserGroups scan: %w", err) + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +// DeleteUserGroup removes a group and, by cascade, its members and grants. +func (s *Store) DeleteUserGroup(ctx context.Context, tenantID, groupID string) error { + result, err := s.pool.Exec(ctx, + `DELETE FROM user_groups WHERE tenant_id = $1 AND id = $2`, + tenantID, groupID, + ) + if err != nil { + return fmt.Errorf("DeleteUserGroup: %w", err) + } + if result.RowsAffected() == 0 { + return fmt.Errorf("user group not found") + } + return nil +} + +// AddUserGroupMember adds a user to a group. It is idempotent. +func (s *Store) AddUserGroupMember(ctx context.Context, tenantID, groupID, userID string) error { + result, err := s.pool.Exec(ctx, + `INSERT INTO user_group_members (group_id, user_id) + SELECT g.id, $3 FROM user_groups g WHERE g.id = $2 AND g.tenant_id = $1 + ON CONFLICT (group_id, user_id) DO NOTHING`, + tenantID, groupID, userID, + ) + if err != nil { + return fmt.Errorf("AddUserGroupMember: %w", err) + } + if result.RowsAffected() == 0 { + // Either the group does not belong to the tenant or the row already exists. + if _, err := s.GetUserGroup(ctx, tenantID, groupID); err != nil { + return fmt.Errorf("user group not found") + } + } + return nil +} + +// RemoveUserGroupMember removes a user from a group. +func (s *Store) RemoveUserGroupMember(ctx context.Context, tenantID, groupID, userID string) error { + _, err := s.pool.Exec(ctx, + `DELETE FROM user_group_members m + USING user_groups g + WHERE m.group_id = g.id AND g.tenant_id = $1 AND m.group_id = $2 AND m.user_id = $3`, + tenantID, groupID, userID, + ) + if err != nil { + return fmt.Errorf("RemoveUserGroupMember: %w", err) + } + return nil +} + +// ListUserGroupMembers returns the members of a group with their email addresses. +func (s *Store) ListUserGroupMembers(ctx context.Context, tenantID, groupID string) ([]*models.UserGroupMember, error) { + rows, err := s.pool.Query(ctx, + `SELECT m.group_id, m.user_id, u.email, m.added_at + FROM user_group_members m + JOIN user_groups g ON g.id = m.group_id + JOIN users u ON u.id = m.user_id + WHERE g.tenant_id = $1 AND m.group_id = $2 + ORDER BY m.added_at ASC`, + tenantID, groupID, + ) + if err != nil { + return nil, fmt.Errorf("ListUserGroupMembers query: %w", err) + } + defer rows.Close() + + members := []*models.UserGroupMember{} + for rows.Next() { + var m models.UserGroupMember + if err := rows.Scan(&m.GroupID, &m.UserID, &m.Email, &m.AddedAt); err != nil { + return nil, fmt.Errorf("ListUserGroupMembers scan: %w", err) + } + members = append(members, &m) + } + return members, rows.Err() +} + +// GrantCredentialProfileToGroup grants a group access to a credential profile. +// Both the group and the profile must belong to the tenant. +func (s *Store) GrantCredentialProfileToGroup(ctx context.Context, tenantID, groupID, profileID string) error { + result, err := s.pool.Exec(ctx, + `INSERT INTO integration_group_grants (group_id, credential_profile_id) + SELECT g.id, p.id + FROM user_groups g + JOIN integration_credential_profiles p ON p.tenant_id = g.tenant_id + WHERE g.tenant_id = $1 AND g.id = $2 AND p.id = $3 AND p.deleted_at IS NULL + ON CONFLICT (group_id, credential_profile_id) DO NOTHING`, + tenantID, groupID, profileID, + ) + if err != nil { + return fmt.Errorf("GrantCredentialProfileToGroup: %w", err) + } + if result.RowsAffected() == 0 { + granted, err := s.CredentialProfileGrantedToGroups(ctx, profileID, []string{groupID}) + if err != nil { + return err + } + if !granted { + return fmt.Errorf("user group or credential profile not found") + } + } + return nil +} + +// RevokeCredentialProfileFromGroup removes a grant. +func (s *Store) RevokeCredentialProfileFromGroup(ctx context.Context, tenantID, groupID, profileID string) error { + _, err := s.pool.Exec(ctx, + `DELETE FROM integration_group_grants gr + USING user_groups g + WHERE gr.group_id = g.id AND g.tenant_id = $1 + AND gr.group_id = $2 AND gr.credential_profile_id = $3`, + tenantID, groupID, profileID, + ) + if err != nil { + return fmt.Errorf("RevokeCredentialProfileFromGroup: %w", err) + } + return nil +} + +// ListGrantsForCredentialProfile returns every group grant on a credential profile. +func (s *Store) ListGrantsForCredentialProfile(ctx context.Context, tenantID, profileID string) ([]*models.IntegrationGroupGrant, error) { + return s.listGrants(ctx, + `SELECT gr.group_id, g.name, gr.credential_profile_id, gr.granted_at + FROM integration_group_grants gr + JOIN user_groups g ON g.id = gr.group_id + WHERE g.tenant_id = $1 AND gr.credential_profile_id = $2 + ORDER BY gr.granted_at ASC`, + tenantID, profileID) +} + +// ListGrantsForGroup returns every credential profile granted to a group. +func (s *Store) ListGrantsForGroup(ctx context.Context, tenantID, groupID string) ([]*models.IntegrationGroupGrant, error) { + return s.listGrants(ctx, + `SELECT gr.group_id, g.name, gr.credential_profile_id, gr.granted_at + FROM integration_group_grants gr + JOIN user_groups g ON g.id = gr.group_id + WHERE g.tenant_id = $1 AND gr.group_id = $2 + ORDER BY gr.granted_at ASC`, + tenantID, groupID) +} + +func (s *Store) listGrants(ctx context.Context, query, tenantID, id string) ([]*models.IntegrationGroupGrant, error) { + rows, err := s.pool.Query(ctx, query, tenantID, id) + if err != nil { + return nil, fmt.Errorf("list integration group grants query: %w", err) + } + defer rows.Close() + + grants := []*models.IntegrationGroupGrant{} + for rows.Next() { + var g models.IntegrationGroupGrant + if err := rows.Scan(&g.GroupID, &g.GroupName, &g.CredentialProfileID, &g.GrantedAt); err != nil { + return nil, fmt.Errorf("list integration group grants scan: %w", err) + } + grants = append(grants, &g) + } + return grants, rows.Err() +} + +// ListGroupIDsForUser returns the IDs of the tenant's groups the user belongs to. +func (s *Store) ListGroupIDsForUser(ctx context.Context, tenantID, userID string) ([]string, error) { + if userID == "" { + return []string{}, nil + } + rows, err := s.pool.Query(ctx, + `SELECT m.group_id + FROM user_group_members m + JOIN user_groups g ON g.id = m.group_id + WHERE g.tenant_id = $1 AND m.user_id = $2 + ORDER BY m.group_id`, + tenantID, userID, + ) + if err != nil { + return nil, fmt.Errorf("ListGroupIDsForUser query: %w", err) + } + defer rows.Close() + return scanIDs(rows, "ListGroupIDsForUser") +} + +// ListGroupIDsForAPIKey returns the group IDs an API key inherits from the user +// that created it. Legacy keys without a user return an empty slice. +func (s *Store) ListGroupIDsForAPIKey(ctx context.Context, tenantID, apiKeyID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT m.group_id + FROM api_keys k + JOIN user_group_members m ON m.user_id = k.user_id + JOIN user_groups g ON g.id = m.group_id AND g.tenant_id = k.tenant_id + WHERE k.tenant_id = $1 AND k.id = $2 + ORDER BY m.group_id`, + tenantID, apiKeyID, + ) + if err != nil { + return nil, fmt.Errorf("ListGroupIDsForAPIKey query: %w", err) + } + defer rows.Close() + return scanIDs(rows, "ListGroupIDsForAPIKey") +} + +// ListCredentialProfileIDsForGroups returns the credential profiles granted to +// any of the given groups. +func (s *Store) ListCredentialProfileIDsForGroups(ctx context.Context, tenantID string, groupIDs []string) ([]string, error) { + if len(groupIDs) == 0 { + return []string{}, nil + } + rows, err := s.pool.Query(ctx, + `SELECT DISTINCT gr.credential_profile_id + FROM integration_group_grants gr + JOIN user_groups g ON g.id = gr.group_id + WHERE g.tenant_id = $1 AND gr.group_id = ANY($2)`, + tenantID, groupIDs, + ) + if err != nil { + return nil, fmt.Errorf("ListCredentialProfileIDsForGroups query: %w", err) + } + defer rows.Close() + return scanIDs(rows, "ListCredentialProfileIDsForGroups") +} + +// CredentialProfileGrantedToGroups reports whether at least one of the groups +// has been granted the credential profile. +func (s *Store) CredentialProfileGrantedToGroups(ctx context.Context, profileID string, groupIDs []string) (bool, error) { + if profileID == "" || len(groupIDs) == 0 { + return false, nil + } + var granted bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS( + SELECT 1 FROM integration_group_grants + WHERE credential_profile_id = $1 AND group_id = ANY($2) + )`, + profileID, groupIDs, + ).Scan(&granted) + if err != nil { + return false, fmt.Errorf("CredentialProfileGrantedToGroups: %w", err) + } + return granted, nil +} + +// CredentialProfileHasGrants reports whether a profile is restricted to groups +// at all. Profiles without any grant stay usable by every member of the tenant. +func (s *Store) CredentialProfileHasGrants(ctx context.Context, profileID string) (bool, error) { + if profileID == "" { + return false, nil + } + var restricted bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM integration_group_grants WHERE credential_profile_id = $1)`, + profileID, + ).Scan(&restricted) + if err != nil { + return false, fmt.Errorf("CredentialProfileHasGrants: %w", err) + } + return restricted, nil +} + +// ListRestrictedCredentialProfileIDs returns every profile of the tenant that +// has at least one group grant. +func (s *Store) ListRestrictedCredentialProfileIDs(ctx context.Context, tenantID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT DISTINCT gr.credential_profile_id + FROM integration_group_grants gr + JOIN user_groups g ON g.id = gr.group_id + WHERE g.tenant_id = $1`, + tenantID, + ) + if err != nil { + return nil, fmt.Errorf("ListRestrictedCredentialProfileIDs query: %w", err) + } + defer rows.Close() + return scanIDs(rows, "ListRestrictedCredentialProfileIDs") +} + +// TenantRBACStrictMode reports whether the tenant denies integration access to +// callers without a resolvable user (legacy tenant-wide API keys). +func (s *Store) TenantRBACStrictMode(ctx context.Context, tenantID string) (bool, error) { + var strict bool + if err := s.pool.QueryRow(ctx, + `SELECT rbac_strict_mode FROM tenants WHERE id = $1`, tenantID, + ).Scan(&strict); err != nil { + return false, fmt.Errorf("TenantRBACStrictMode: %w", err) + } + return strict, nil +} + +// SetTenantRBACStrictMode toggles strict mode for a tenant. +func (s *Store) SetTenantRBACStrictMode(ctx context.Context, tenantID string, strict bool) error { + _, err := s.pool.Exec(ctx, + `UPDATE tenants SET rbac_strict_mode = $2 WHERE id = $1`, tenantID, strict, + ) + if err != nil { + return fmt.Errorf("SetTenantRBACStrictMode: %w", err) + } + return nil +} + +type rowsScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +func scanIDs(rows rowsScanner, label string) ([]string, error) { + out := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("%s scan: %w", label, err) + } + out = append(out, id) + } + return out, rows.Err() +} + +func scanUserGroup(row scannable) (*models.UserGroup, error) { + var g models.UserGroup + if err := row.Scan(&g.ID, &g.TenantID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, err + } + return &g, nil +} diff --git a/services/control-plane/internal/store/postgres/user_groups_test.go b/services/control-plane/internal/store/postgres/user_groups_test.go new file mode 100644 index 0000000..0de9589 --- /dev/null +++ b/services/control-plane/internal/store/postgres/user_groups_test.go @@ -0,0 +1,239 @@ +package postgres_test + +import ( + "context" + "testing" + + "github.com/abskrj/velane/services/control-plane/internal/models" + "github.com/abskrj/velane/services/control-plane/internal/store/postgres" +) + +// seedGroupFixture creates a tenant, a member user and a credential profile. +func seedGroupFixture(t *testing.T, store *postgres.Store) (tenantID, userID, profileID string) { + t.Helper() + ctx := context.Background() + + tenant, err := store.CreateTenant(ctx, "Groups Org", uniqueSlug(t, "groups-org")) + if err != nil { + t.Fatalf("CreateTenant: %v", err) + } + user, err := store.CreateUserNoPassword(ctx, uniqueSlug(t, "member")+"@example.com") + if err != nil { + t.Fatalf("CreateUserNoPassword: %v", err) + } + if _, err := store.AddMember(ctx, tenant.ID, user.ID, models.RoleMember); err != nil { + t.Fatalf("AddMember: %v", err) + } + profile, err := store.UpsertIntegrationCredentialProfile( + ctx, tenant.ID, "github", uniqueSlug(t, "alias"), "GitHub", user.ID, + "OAUTH2", map[string]string{}, "", true, "", []byte("0123456789abcdef0123456789abcdef"), + ) + if err != nil { + t.Fatalf("UpsertIntegrationCredentialProfile: %v", err) + } + return tenant.ID, user.ID, profile.ID +} + +func TestUserGroupLifecycle(t *testing.T) { + store := testStore(t) + ctx := context.Background() + tenantID, userID, profileID := seedGroupFixture(t, store) + + group, err := store.CreateUserGroup(ctx, tenantID, "support", "Support engineers") + if err != nil { + t.Fatalf("CreateUserGroup: %v", err) + } + if group.TenantID != tenantID || group.Name != "support" { + t.Fatalf("unexpected group %+v", group) + } + + groups, err := store.ListUserGroups(ctx, tenantID) + if err != nil { + t.Fatalf("ListUserGroups: %v", err) + } + if len(groups) != 1 { + t.Fatalf("group count = %d; want 1", len(groups)) + } + + if err := store.AddUserGroupMember(ctx, tenantID, group.ID, userID); err != nil { + t.Fatalf("AddUserGroupMember: %v", err) + } + // Adding twice must be idempotent. + if err := store.AddUserGroupMember(ctx, tenantID, group.ID, userID); err != nil { + t.Fatalf("AddUserGroupMember (repeat): %v", err) + } + members, err := store.ListUserGroupMembers(ctx, tenantID, group.ID) + if err != nil { + t.Fatalf("ListUserGroupMembers: %v", err) + } + if len(members) != 1 || members[0].UserID != userID { + t.Fatalf("unexpected members %+v", members) + } + + userGroups, err := store.ListGroupIDsForUser(ctx, tenantID, userID) + if err != nil { + t.Fatalf("ListGroupIDsForUser: %v", err) + } + if len(userGroups) != 1 || userGroups[0] != group.ID { + t.Fatalf("user groups = %v; want [%s]", userGroups, group.ID) + } + + if err := store.GrantCredentialProfileToGroup(ctx, tenantID, group.ID, profileID); err != nil { + t.Fatalf("GrantCredentialProfileToGroup: %v", err) + } + + granted, err := store.CredentialProfileGrantedToGroups(ctx, profileID, []string{group.ID}) + if err != nil { + t.Fatalf("CredentialProfileGrantedToGroups: %v", err) + } + if !granted { + t.Error("profile must be granted to the group it was granted to") + } + granted, err = store.CredentialProfileGrantedToGroups(ctx, profileID, []string{"other-group"}) + if err != nil { + t.Fatalf("CredentialProfileGrantedToGroups (other): %v", err) + } + if granted { + t.Error("profile must not be granted to an unrelated group") + } + + restricted, err := store.CredentialProfileHasGrants(ctx, profileID) + if err != nil { + t.Fatalf("CredentialProfileHasGrants: %v", err) + } + if !restricted { + t.Error("a profile with grants must be reported as restricted") + } + + profileIDs, err := store.ListCredentialProfileIDsForGroups(ctx, tenantID, []string{group.ID}) + if err != nil { + t.Fatalf("ListCredentialProfileIDsForGroups: %v", err) + } + if len(profileIDs) != 1 || profileIDs[0] != profileID { + t.Fatalf("granted profiles = %v; want [%s]", profileIDs, profileID) + } + + if err := store.RevokeCredentialProfileFromGroup(ctx, tenantID, group.ID, profileID); err != nil { + t.Fatalf("RevokeCredentialProfileFromGroup: %v", err) + } + restricted, err = store.CredentialProfileHasGrants(ctx, profileID) + if err != nil { + t.Fatalf("CredentialProfileHasGrants after revoke: %v", err) + } + if restricted { + t.Error("profile must be unrestricted once its last grant is revoked") + } + + if err := store.DeleteUserGroup(ctx, tenantID, group.ID); err != nil { + t.Fatalf("DeleteUserGroup: %v", err) + } + groups, err = store.ListUserGroups(ctx, tenantID) + if err != nil { + t.Fatalf("ListUserGroups after delete: %v", err) + } + if len(groups) != 0 { + t.Fatalf("group count after delete = %d; want 0", len(groups)) + } +} + +func TestUserGroupsAreTenantScoped(t *testing.T) { + store := testStore(t) + ctx := context.Background() + tenantID, userID, _ := seedGroupFixture(t, store) + + other, err := store.CreateTenant(ctx, "Other Org", uniqueSlug(t, "other-org")) + if err != nil { + t.Fatalf("CreateTenant: %v", err) + } + + group, err := store.CreateUserGroup(ctx, tenantID, "engineers", "") + if err != nil { + t.Fatalf("CreateUserGroup: %v", err) + } + + if err := store.AddUserGroupMember(ctx, other.ID, group.ID, userID); err == nil { + t.Error("adding a member through a foreign tenant must fail") + } + if err := store.DeleteUserGroup(ctx, other.ID, group.ID); err == nil { + t.Error("deleting a group through a foreign tenant must fail") + } + + groups, err := store.ListUserGroups(ctx, other.ID) + if err != nil { + t.Fatalf("ListUserGroups: %v", err) + } + if len(groups) != 0 { + t.Fatalf("foreign tenant sees %d groups; want 0", len(groups)) + } +} + +func TestAPIKeyInheritsCreatorGroups(t *testing.T) { + store := testStore(t) + ctx := context.Background() + tenantID, userID, _ := seedGroupFixture(t, store) + + group, err := store.CreateUserGroup(ctx, tenantID, "key-owners", "") + if err != nil { + t.Fatalf("CreateUserGroup: %v", err) + } + if err := store.AddUserGroupMember(ctx, tenantID, group.ID, userID); err != nil { + t.Fatalf("AddUserGroupMember: %v", err) + } + + key, _, err := store.CreateAPIKeyWithPlainForUser(ctx, tenantID, "owned", []string{"invoke"}, &userID) + if err != nil { + t.Fatalf("CreateAPIKeyWithPlainForUser: %v", err) + } + if key.UserID == nil || *key.UserID != userID { + t.Fatalf("key user_id = %v; want %s", key.UserID, userID) + } + + groupIDs, err := store.ListGroupIDsForAPIKey(ctx, tenantID, key.ID) + if err != nil { + t.Fatalf("ListGroupIDsForAPIKey: %v", err) + } + if len(groupIDs) != 1 || groupIDs[0] != group.ID { + t.Fatalf("key groups = %v; want [%s]", groupIDs, group.ID) + } + + // Legacy keys have no owner and therefore no group access. + legacy, _, err := store.CreateAPIKeyWithPlain(ctx, tenantID, "legacy", []string{"invoke"}) + if err != nil { + t.Fatalf("CreateAPIKeyWithPlain: %v", err) + } + if legacy.UserID != nil { + t.Fatalf("legacy key user_id = %v; want nil", legacy.UserID) + } + groupIDs, err = store.ListGroupIDsForAPIKey(ctx, tenantID, legacy.ID) + if err != nil { + t.Fatalf("ListGroupIDsForAPIKey (legacy): %v", err) + } + if len(groupIDs) != 0 { + t.Fatalf("legacy key groups = %v; want none", groupIDs) + } +} + +func TestTenantRBACStrictMode(t *testing.T) { + store := testStore(t) + ctx := context.Background() + tenantID, _, _ := seedGroupFixture(t, store) + + strict, err := store.TenantRBACStrictMode(ctx, tenantID) + if err != nil { + t.Fatalf("TenantRBACStrictMode: %v", err) + } + if strict { + t.Error("strict mode must default to false for backward compatibility") + } + + if err := store.SetTenantRBACStrictMode(ctx, tenantID, true); err != nil { + t.Fatalf("SetTenantRBACStrictMode: %v", err) + } + strict, err = store.TenantRBACStrictMode(ctx, tenantID) + if err != nil { + t.Fatalf("TenantRBACStrictMode after set: %v", err) + } + if !strict { + t.Error("strict mode must be true once enabled") + } +} From b05f728a1e7288ed831e1e4cfdc1bd859232f73e Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 27 Aug 2026 09:56:06 +0000 Subject: [PATCH 2/2] Keep legacy tenant-wide keys tenant-wide at the proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/api/handlers/connections.go | 24 +++++++++++-------- .../internal/api/user_groups_test.go | 14 ++++++++--- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/services/control-plane/internal/api/handlers/connections.go b/services/control-plane/internal/api/handlers/connections.go index 6fa2e1a..ac6d4d8 100644 --- a/services/control-plane/internal/api/handlers/connections.go +++ b/services/control-plane/internal/api/handlers/connections.go @@ -417,8 +417,9 @@ func (h *ConnectionsHandler) Proxy(w http.ResponseWriter, r *http.Request) { } // callerMayUseConnection verifies the signed caller token against the group -// grants on the connection's credential profile. A missing or unverifiable -// token keeps the pre-RBAC behaviour unless the tenant enabled strict mode. +// grants on the connection's credential profile. A missing, unverifiable or +// user-less token keeps the pre-RBAC behaviour unless the tenant enabled strict +// mode, matching the visibility rule the list endpoints apply. func (h *ConnectionsHandler) callerMayUseConnection(r *http.Request, tenantID string, conn *models.Connection) bool { profileID := "" if conn.CredentialProfileID != nil { @@ -436,10 +437,17 @@ func (h *ConnectionsHandler) callerMayUseConnection(r *http.Request, tenantID st } claims := h.verifiedCallerClaims(r, tenantID) - if claims == nil { - if !restricted { - return true - } + if claims != nil && models.RoleSeesAllIntegrations(claims.Role) { + return true + } + if !restricted { + return true + } + + // No identified user behind the call — either no token at all or a legacy + // tenant-wide API key, which owns no groups. Both stay tenant-wide unless + // the tenant opted into strict mode. + if claims == nil || claims.UserID == "" { strict, err := h.store.TenantRBACStrictMode(r.Context(), tenantID) if err != nil { h.log.Error("proxy: read tenant strict mode", zap.Error(err)) @@ -448,10 +456,6 @@ func (h *ConnectionsHandler) callerMayUseConnection(r *http.Request, tenantID st return !strict } - if models.RoleSeesAllIntegrations(claims.Role) || !restricted { - return true - } - granted, err := h.store.CredentialProfileGrantedToGroups(r.Context(), profileID, claims.GroupIDs) if err != nil { h.log.Error("proxy: check group grant", zap.Error(err)) diff --git a/services/control-plane/internal/api/user_groups_test.go b/services/control-plane/internal/api/user_groups_test.go index 3b51256..52a5b01 100644 --- a/services/control-plane/internal/api/user_groups_test.go +++ b/services/control-plane/internal/api/user_groups_test.go @@ -237,10 +237,10 @@ func TestProxy_EnforcesGroupGrants(t *testing.T) { return token } - if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { + if code := proxy(sign(callerid.Claims{UserID: "user-1", Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { t.Errorf("granted member proxy status = %d; want 200", code) } - if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{"unrelated"}})); code != http.StatusForbidden { + if code := proxy(sign(callerid.Claims{UserID: "user-2", Role: models.RoleMember, GroupIDs: []string{"unrelated"}})); code != http.StatusForbidden { t.Errorf("ungranted member proxy status = %d; want 403", code) } if code := proxy(sign(callerid.Claims{Role: models.RoleAdmin})); code != http.StatusOK { @@ -255,6 +255,9 @@ func TestProxy_EnforcesGroupGrants(t *testing.T) { if code := proxy(""); code != http.StatusOK { t.Errorf("tokenless proxy status = %d; want 200", code) } + if code := proxy(sign(callerid.Claims{Role: models.RoleInvoke})); code != http.StatusOK { + t.Errorf("legacy tenant-wide key proxy status = %d; want 200", code) + } // With strict mode enabled, unidentified callers are denied. if err := env.store.SetTenantRBACStrictMode(context.Background(), env.tenant.ID, true); err != nil { @@ -263,7 +266,12 @@ func TestProxy_EnforcesGroupGrants(t *testing.T) { if code := proxy(""); code != http.StatusForbidden { t.Errorf("strict-mode tokenless proxy status = %d; want 403", code) } - if code := proxy(sign(callerid.Claims{Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { + // A legacy tenant-wide API key mints a token with a role but no user; it + // keeps tenant-wide access until strict mode is on. + if code := proxy(sign(callerid.Claims{Role: models.RoleInvoke})); code != http.StatusForbidden { + t.Errorf("strict-mode legacy-key proxy status = %d; want 403", code) + } + if code := proxy(sign(callerid.Claims{UserID: "user-1", Role: models.RoleMember, GroupIDs: []string{groupID}})); code != http.StatusOK { t.Errorf("strict-mode granted member proxy status = %d; want 200", code) } }