diff --git a/CHANGELOG.md b/CHANGELOG.md index d41d211..b432da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,60 @@ All notable changes to Armature are documented here. +## v0.9.3 — Team User Roles + +Promotes the team role system from a single-role-per-member model to a +many-to-many relationship, enabling users to hold multiple roles within +a team simultaneously. + +**Schema** + +- Migration 016: `team_user_roles` table (team_id, user_id, role) with + unique constraint and compound index. Both Postgres and SQLite dialects. + +**Store + Models** + +- `TeamUserRole` model struct. +- 6 new `TeamStore` methods: `AddUserRole`, `RemoveUserRole`, + `ListUserRoles`, `GetMemberRoles` (union of primary + additional), + `HasRole`, `RemoveAllUserRoles`. +- Implementations for both Postgres and SQLite stores. + +**Middleware** + +- `RequireRole(teams, roles, stores)` — kernel middleware that checks + whether the user holds at least one of the required roles (OR semantics). + System admin bypass via permissions. + +**Handlers** + +- `GET /teams/:teamId/members/:memberId/roles` — list full role set. +- `POST /teams/:teamId/members/:memberId/roles` — assign additional role. +- `DELETE /teams/:teamId/members/:memberId/roles/:role` — remove role. +- `RemoveMember` handler now cleans up `team_user_roles` on member removal. + +**Manifest** + +- `requires_roles` field parsed from package manifests (advisory in v0.9.3; + extensions gate via `teams.has_role()` in Starlark). + +**Starlark SDK** + +- New `teams` module wired into sandbox runner: + - `teams.get_member_roles(team_id, user_id)` → list of strings. + - `teams.has_role(team_id, user_id, role)` → True/False. + +**Admin UI** + +- Team-admin members page: removable badge chips for additional roles, + "+ Role" dropdown for assignment. +- Fixed pre-existing SDK auto-unwrap bug in `loadRoles` / `loadMemberRoles`. + +**Tests** + +- 10 new tests: store CRUD (add, idempotent, has_role, remove, removeAll), + middleware (allowed, denied), manifest parsing (valid, empty, invalid). + ## v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup Consolidates duplicated Go↔Starlark conversion code and snapshot diff --git a/ROADMAP.md b/ROADMAP.md index 09c7470..8fee870 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,12 +98,12 @@ Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go` Standardized on wrapped snapshot format. ~350 lines of duplication removed. Design doc: `docs/DESIGN-workflow-redesign.md`. -**v0.9.3 — Team User Roles** +**v0.9.3 — Team User Roles** *(completed)* -Promote the buried role system to a kernel primitive. New -`team_user_roles` table (many-to-many). Manifest `requires_roles` field. -Team admin UI for role assignment. Kernel middleware `RequireRole()`. -Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`. +Many-to-many `team_user_roles` table. `RequireRole()` middleware. +Manifest `requires_roles` field (advisory). Starlark `teams` module +with `get_member_roles()` and `has_role()`. Team-admin UI with role +badge chips and assignment dropdown. 10 new tests. **v0.9.4 — Package Adoption + Roles** diff --git a/VERSION b/VERSION index 2003b63..965065d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.2 +0.9.3 diff --git a/server/database/migrations/postgres/016_team_user_roles.sql b/server/database/migrations/postgres/016_team_user_roles.sql new file mode 100644 index 0000000..6fa09c2 --- /dev/null +++ b/server/database/migrations/postgres/016_team_user_roles.sql @@ -0,0 +1,17 @@ +-- ========================================== +-- Armature — 016 Team User Roles (many-to-many) +-- ========================================== + +CREATE TABLE IF NOT EXISTS team_user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + assigned_by UUID REFERENCES users(id) ON DELETE SET NULL, + assigned_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(team_id, user_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_team_user_roles_team_user ON team_user_roles(team_id, user_id); +CREATE INDEX IF NOT EXISTS idx_team_user_roles_team ON team_user_roles(team_id); +CREATE INDEX IF NOT EXISTS idx_team_user_roles_user ON team_user_roles(user_id); diff --git a/server/database/migrations/sqlite/016_team_user_roles.sql b/server/database/migrations/sqlite/016_team_user_roles.sql new file mode 100644 index 0000000..87b7a5c --- /dev/null +++ b/server/database/migrations/sqlite/016_team_user_roles.sql @@ -0,0 +1,17 @@ +-- ========================================== +-- Armature — 016 Team User Roles (many-to-many, SQLite) +-- ========================================== + +CREATE TABLE IF NOT EXISTS team_user_roles ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + assigned_by TEXT REFERENCES users(id) ON DELETE SET NULL, + assigned_at TEXT DEFAULT (datetime('now')), + UNIQUE(team_id, user_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_team_user_roles_team_user ON team_user_roles(team_id, user_id); +CREATE INDEX IF NOT EXISTS idx_team_user_roles_team ON team_user_roles(team_id); +CREATE INDEX IF NOT EXISTS idx_team_user_roles_user ON team_user_roles(user_id); diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index ff962c9..c730644 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -33,6 +33,7 @@ type ManifestInfo struct { Signature string // reserved for future package signing UserPermissions []string // user-facing permissions declared by the extension GatePermission string // if set, checks this user permission before calling on_request + RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) } // ValidateManifest parses a manifest map and validates all required fields, @@ -168,6 +169,15 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { } info.GatePermission, _ = manifest["gate_permission"].(string) + // v0.9.3: requires_roles — team roles needed to access this package (advisory) + if rr, ok := manifest["requires_roles"].([]any); ok { + for _, v := range rr { + if s, ok := v.(string); ok && s != "" && len(s) <= 50 { + info.RequiresRoles = append(info.RequiresRoles, s) + } + } + } + info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── diff --git a/server/handlers/team_roles_test.go b/server/handlers/team_roles_test.go new file mode 100644 index 0000000..666cfad --- /dev/null +++ b/server/handlers/team_roles_test.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "armature/database" + "armature/middleware" + "armature/models" + "armature/store" +) + +// ── Manifest: requires_roles parsing ────────── + +func TestValidateManifest_RequiresRoles(t *testing.T) { + m := map[string]any{ + "id": "role-gated", + "title": "Role Gated Ext", + "type": "surface", + "requires_roles": []any{"approver", "reviewer"}, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(info.RequiresRoles) != 2 { + t.Fatalf("expected 2 requires_roles, got %d", len(info.RequiresRoles)) + } + if info.RequiresRoles[0] != "approver" || info.RequiresRoles[1] != "reviewer" { + t.Errorf("unexpected roles: %v", info.RequiresRoles) + } +} + +func TestValidateManifest_RequiresRoles_Empty(t *testing.T) { + m := map[string]any{ + "id": "no-roles", + "title": "No Roles", + "type": "surface", + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(info.RequiresRoles) != 0 { + t.Errorf("expected empty requires_roles, got %v", info.RequiresRoles) + } +} + +func TestValidateManifest_RequiresRoles_SkipsInvalid(t *testing.T) { + m := map[string]any{ + "id": "bad-roles", + "title": "Bad Roles", + "type": "surface", + "requires_roles": []any{"ok", "", 42, "also-ok"}, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(info.RequiresRoles) != 2 { + t.Fatalf("expected 2 valid roles, got %d: %v", len(info.RequiresRoles), info.RequiresRoles) + } +} + +// ── Store: team_user_roles CRUD ────────────── + +func seedTeamAndMember(t *testing.T, stores store.Stores) (teamID, userID, memberID string) { + t.Helper() + ctx := context.Background() + + userID = seedRoleUser(t, "roleuser", "roleuser@test.com") + creatorID := seedRoleUser(t, "creator", "creator@test.com") + + team := &models.Team{Name: "role-test-" + store.NewID()[:8], CreatedBy: creatorID, IsActive: true} + if err := stores.Teams.Create(ctx, team); err != nil { + t.Fatalf("create team: %v", err) + } + teamID = team.ID + + mid, err := stores.Teams.AddMemberReturningID(ctx, teamID, userID, "member") + if err != nil { + t.Fatalf("add member: %v", err) + } + memberID = mid + return +} + +func TestUserRoles_AddAndList(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + + // Initially: only the primary role + roles, err := stores.Teams.GetMemberRoles(ctx, teamID, userID) + if err != nil { + t.Fatalf("GetMemberRoles: %v", err) + } + if len(roles) != 1 || roles[0] != "member" { + t.Errorf("expected [member], got %v", roles) + } + + // Add two additional roles + if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { + t.Fatalf("AddUserRole: %v", err) + } + if err := stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID); err != nil { + t.Fatalf("AddUserRole: %v", err) + } + + roles, _ = stores.Teams.GetMemberRoles(ctx, teamID, userID) + if len(roles) != 3 { + t.Fatalf("expected 3 roles, got %d: %v", len(roles), roles) + } + + // ListUserRoles returns only additional + extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) + if len(extra) != 2 { + t.Fatalf("expected 2 extra roles, got %d: %v", len(extra), extra) + } +} + +func TestUserRoles_AddIdempotent(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + + // Add same role twice — should not error + if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { + t.Fatalf("first add: %v", err) + } + if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { + t.Fatalf("idempotent add should not error: %v", err) + } + + extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) + if len(extra) != 1 { + t.Errorf("expected 1 extra role after idempotent add, got %d", len(extra)) + } +} + +func TestUserRoles_HasRole(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + + // Primary role + has, _ := stores.Teams.HasRole(ctx, teamID, userID, "member") + if !has { + t.Error("expected HasRole=true for primary role 'member'") + } + + // Non-existent role + has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer") + if has { + t.Error("expected HasRole=false for unassigned role") + } + + // Add and check + stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) + has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer") + if !has { + t.Error("expected HasRole=true after adding role") + } +} + +func TestUserRoles_Remove(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + + stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) + stores.Teams.RemoveUserRole(ctx, teamID, userID, "reviewer") + + extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) + if len(extra) != 0 { + t.Errorf("expected 0 extra roles after remove, got %d", len(extra)) + } +} + +func TestUserRoles_RemoveAllOnMemberDelete(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + + stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) + stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID) + + // Cleanup (as the handler does) + stores.Teams.RemoveAllUserRoles(ctx, teamID, userID) + + extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) + if len(extra) != 0 { + t.Errorf("expected 0 extra roles after RemoveAll, got %d", len(extra)) + } +} + +// ── Middleware: RequireRole ─────────────────── + +func TestRequireRole_Allowed(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + + called := false + r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) + r.GET("/teams/:teamId/test", + middleware.RequireRole(stores.Teams, []string{"reviewer"}, stores), + func(c *gin.Context) { + called = true + c.Status(http.StatusOK) + }, + ) + req := httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !called { + t.Errorf("expected 200 + handler called, got %d called=%v", w.Code, called) + } +} + +func TestRequireRole_Denied(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + + teamID, userID, _ := seedTeamAndMember(t, stores) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) + r.GET("/teams/:teamId/test", + middleware.RequireRole(stores.Teams, []string{"approver"}, stores), + func(c *gin.Context) { c.Status(http.StatusOK) }, + ) + c.Request = httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil) + r.ServeHTTP(w, c.Request) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for missing role, got %d", w.Code) + } +} + +// ── helpers ────────────────────────────────── + +func seedRoleUser(t *testing.T, username, email string) string { + t.Helper() + uname := username + store.NewID()[:6] + q := `INSERT INTO users (username, email, password_hash, display_name, is_active, auth_source, handle) + VALUES ($1, $2, 'hash', $3, true, 'builtin', $4) RETURNING id` + return seedInsertReturningID(t, q, uname, email+store.NewID()[:6], uname, uname) +} diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 6977e2c..26b7f31 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -360,6 +360,11 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) { return } + // Clean up additional roles from team_user_roles + if affectedUID != "" { + _ = h.stores.Teams.RemoveAllUserRoles(c.Request.Context(), teamID, affectedUID) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) if affectedUID != "" { h.notifyAuthChanged(affectedUID, "team_member_removed") @@ -449,6 +454,106 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"actions": actions}) } +// ── Member User Roles (many-to-many) ───────── + +// ListMemberRoles returns the full effective role set for a team member. +// GET /api/v1/teams/:teamId/members/:memberId/roles +func (h *TeamHandler) ListMemberRoles(c *gin.Context) { + teamID := getTeamID(c) + memberID := c.Param("memberId") + + uid := h.memberUserID(c.Request.Context(), teamID, memberID) + if uid == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) + return + } + + roles, err := h.stores.Teams.GetMemberRoles(c.Request.Context(), teamID, uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + // Also fetch the primary role for the response + member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid) + primary := "" + if member != nil { + primary = member.Role + } + + c.JSON(http.StatusOK, gin.H{"data": roles, "primary": primary}) +} + +// AddMemberRole assigns an additional role to a team member. +// POST /api/v1/teams/:teamId/members/:memberId/roles +func (h *TeamHandler) AddMemberRole(c *gin.Context) { + teamID := getTeamID(c) + memberID := c.Param("memberId") + + var req struct { + Role string `json:"role" binding:"required,min=1,max=50"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + uid := h.memberUserID(c.Request.Context(), teamID, memberID) + if uid == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) + return + } + + assignedBy := getUserID(c) + if err := h.stores.Teams.AddUserRole(c.Request.Context(), teamID, uid, req.Role, assignedBy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add role"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"ok": true}) + h.notifyAuthChanged(uid, "team_role_added") + AuditLog(h.stores.Audit, c, "team.add_member_role", "team", teamID, map[string]interface{}{ + "member_id": memberID, "user_id": uid, "role": req.Role, + }) +} + +// RemoveMemberRole removes an additional role from a team member. +// DELETE /api/v1/teams/:teamId/members/:memberId/roles/:role +func (h *TeamHandler) RemoveMemberRole(c *gin.Context) { + teamID := getTeamID(c) + memberID := c.Param("memberId") + role := c.Param("role") + + uid := h.memberUserID(c.Request.Context(), teamID, memberID) + if uid == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) + return + } + + // Don't allow removing the primary role via this endpoint + member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid) + if member != nil && member.Role == role { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot remove primary role; use member update instead"}) + return + } + + if err := h.stores.Teams.RemoveUserRole(c.Request.Context(), teamID, uid, role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove role"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + h.notifyAuthChanged(uid, "team_role_removed") + AuditLog(h.stores.Audit, c, "team.remove_member_role", "team", teamID, map[string]interface{}{ + "member_id": memberID, "user_id": uid, "role": role, + }) +} + // ── Team Roles API ───────────────── // builtinRoles are always present in every team's role list. diff --git a/server/main.go b/server/main.go index 90259ea..c730a89 100644 --- a/server/main.go +++ b/server/main.go @@ -640,6 +640,9 @@ func main() { teamScoped.POST("/members", teams.AddMember) teamScoped.PUT("/members/:memberId", teams.UpdateMember) teamScoped.DELETE("/members/:memberId", teams.RemoveMember) + teamScoped.GET("/members/:memberId/roles", teams.ListMemberRoles) + teamScoped.POST("/members/:memberId/roles", teams.AddMemberRole) + teamScoped.DELETE("/members/:memberId/roles/:role", teams.RemoveMemberRole) teamScoped.GET("/roles", teams.ListRoles) teamScoped.PUT("/roles", teams.UpdateRoles) diff --git a/server/middleware/team.go b/server/middleware/team.go index fdab64c..0fa75a7 100644 --- a/server/middleware/team.go +++ b/server/middleware/team.go @@ -54,6 +54,51 @@ func RequireTeamAdmin(teams store.TeamStore, allStores ...store.Stores) gin.Hand } } +// RequireRole returns middleware that restricts access to users holding at least +// one of the specified roles in the team identified by :teamId. +// Checks both the primary role (team_members.role) and additional roles +// (team_user_roles). System admins are always allowed through. +func RequireRole(teams store.TeamStore, roles []string, allStores ...store.Stores) gin.HandlerFunc { + return func(c *gin.Context) { + if len(allStores) > 0 && isSystemAdmin(c, allStores[0]) { + c.Next() + return + } + + userID := c.GetString("user_id") + teamID := c.Param("teamId") + if teamID == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "team ID required", + }) + return + } + + memberRoles, err := teams.GetMemberRoles(c.Request.Context(), teamID, userID) + if err != nil || len(memberRoles) == 0 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "required role not held", + }) + return + } + + roleSet := make(map[string]bool, len(memberRoles)) + for _, r := range memberRoles { + roleSet[r] = true + } + for _, required := range roles { + if roleSet[required] { + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "required role not held", + }) + } +} + // RequireTeamMember returns middleware that restricts access to users who // belong to the team identified by :teamId (any role). // System admins are always allowed through. diff --git a/server/models/models.go b/server/models/models.go index c0bd9c6..6dc280a 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -80,6 +80,16 @@ type TeamMember struct { Username string `json:"username,omitempty"` } +// TeamUserRole is a many-to-many join: one user can hold multiple roles in a team. +type TeamUserRole struct { + ID string `json:"id" db:"id"` + TeamID string `json:"team_id" db:"team_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` + AssignedBy string `json:"assigned_by,omitempty" db:"assigned_by"` + AssignedAt string `json:"assigned_at" db:"assigned_at"` +} + // PLATFORM POLICIES var PolicyDefaults = map[string]string{ diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index ce885cc..e596455 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -454,6 +454,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m // Always available — read-only check against kernel permission data modules["permissions"] = BuildPermissionsModule(ctx, r.stores) + // Always available — read-only team role queries + modules["teams"] = BuildTeamsModule(ctx, r.stores) + // Allows any starlark package to load declared library dependencies. if lc != nil { modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc) diff --git a/server/sandbox/teams_module.go b/server/sandbox/teams_module.go new file mode 100644 index 0000000..c57442b --- /dev/null +++ b/server/sandbox/teams_module.go @@ -0,0 +1,62 @@ +// Package sandbox — teams_module.go +// +// Read-only module for querying team role membership from Starlark scripts. +// No sandbox permission required — extensions can check roles without special grants. +// +// Starlark API: +// teams.get_member_roles(team_id, user_id) → ["admin", "reviewer", ...] +// teams.has_role(team_id, user_id, role) → True/False +package sandbox + +import ( + "context" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "armature/store" +) + +// BuildTeamsModule creates the "teams" module. +// Always available to all extensions (no permission gate). +func BuildTeamsModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { + return MakeModule("teams", starlark.StringDict{ + + "get_member_roles": starlark.NewBuiltin("teams.get_member_roles", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var teamID, userID string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &teamID, &userID); err != nil { + return nil, err + } + + roles, err := stores.Teams.GetMemberRoles(ctx, teamID, userID) + if err != nil { + return starlark.NewList(nil), nil + } + + elems := make([]starlark.Value, len(roles)) + for i, r := range roles { + elems[i] = starlark.String(r) + } + return starlark.NewList(elems), nil + }), + + "has_role": starlark.NewBuiltin("teams.has_role", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var teamID, userID, role string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 3, &teamID, &userID, &role); err != nil { + return nil, err + } + + has, err := stores.Teams.HasRole(ctx, teamID, userID, role) + if err != nil || !has { + return starlark.False, nil + } + return starlark.True, nil + }), + }) +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 8af27a9..62a4ac9 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -188,6 +188,26 @@ type TeamStore interface { // MergeSettings merges a JSON string into the team's settings column. MergeSettings(ctx context.Context, teamID, settingsJSON string) error + + // ── v0.9.3 — Team User Roles (many-to-many) ── + + // AddUserRole assigns an additional role to a team member. Idempotent. + AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error + + // RemoveUserRole removes one additional role from a team member. + RemoveUserRole(ctx context.Context, teamID, userID, role string) error + + // ListUserRoles returns the additional roles for a member (excludes primary). + ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) + + // GetMemberRoles returns the full effective role set (primary + additional). + GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) + + // HasRole checks whether a user holds a specific role (primary or additional). + HasRole(ctx context.Context, teamID, userID, role string) (bool, error) + + // RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal). + RemoveAllUserRoles(ctx context.Context, teamID, userID string) error } // ========================================= diff --git a/server/store/postgres/team.go b/server/store/postgres/team.go index 516289a..eb3f36a 100644 --- a/server/store/postgres/team.go +++ b/server/store/postgres/team.go @@ -308,3 +308,88 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri settingsJSON, teamID) return err } + +// ── v0.9.3 — Team User Roles (many-to-many) ────────────────── + +func (s *TeamStore) AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO team_user_roles (team_id, user_id, role, assigned_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (team_id, user_id, role) DO NOTHING`, + teamID, userID, role, assignedBy) + return err +} + +func (s *TeamStore) RemoveUserRole(ctx context.Context, teamID, userID, role string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2 AND role = $3`, + teamID, userID, role) + return err +} + +func (s *TeamStore) ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, + `SELECT role FROM team_user_roles WHERE team_id = $1 AND user_id = $2 ORDER BY role`, + teamID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (s *TeamStore) GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2 + UNION + SELECT role FROM team_user_roles WHERE team_id = $1 AND user_id = $2 + ORDER BY role`, + teamID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (bool, error) { + var exists bool + err := DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3 + UNION ALL + SELECT 1 FROM team_user_roles WHERE team_id = $1 AND user_id = $2 AND role = $3 + )`, teamID, userID, role).Scan(&exists) + return exists, err +} + +func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2`, + teamID, userID) + return err +} diff --git a/server/store/sqlite/team.go b/server/store/sqlite/team.go index 3b96bd8..18c5d74 100644 --- a/server/store/sqlite/team.go +++ b/server/store/sqlite/team.go @@ -314,3 +314,88 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri settingsJSON, teamID) return err } + +// ── v0.9.3 — Team User Roles (many-to-many) ────────────────── + +func (s *TeamStore) AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO team_user_roles (id, team_id, user_id, role, assigned_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (team_id, user_id, role) DO NOTHING`, + store.NewID(), teamID, userID, role, assignedBy) + return err +} + +func (s *TeamStore) RemoveUserRole(ctx context.Context, teamID, userID, role string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ?`, + teamID, userID, role) + return err +} + +func (s *TeamStore) ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, + `SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ? ORDER BY role`, + teamID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (s *TeamStore) GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT role FROM team_members WHERE team_id = ? AND user_id = ? + UNION + SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ? + ORDER BY role`, + teamID, userID, teamID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (bool, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM ( + SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ? AND role = ? + UNION ALL + SELECT 1 FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ? + )`, teamID, userID, role, teamID, userID, role).Scan(&count) + return count > 0, err +} + +func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`, + teamID, userID) + return err +} diff --git a/src/js/sw/surfaces/team-admin/members.js b/src/js/sw/surfaces/team-admin/members.js index b01f951..bfab8b6 100644 --- a/src/js/sw/surfaces/team-admin/members.js +++ b/src/js/sw/surfaces/team-admin/members.js @@ -16,7 +16,7 @@ export default function MembersSection({ teamId }) { const loadRoles = useCallback(async () => { try { const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`); - setRoles(resp.data || ['admin', 'member']); + setRoles(Array.isArray(resp) ? resp : (resp.data || ['admin', 'member'])); } catch { setRoles(['admin', 'member']); } }, [teamId]); @@ -63,6 +63,38 @@ export default function MembersSection({ teamId }) { } catch (e) { sw.toast(e.message, 'error'); } } + // ── Additional roles (many-to-many) ─────── + const [memberRoles, setMemberRoles] = useState({}); + + async function loadMemberRoles(memberId) { + try { + const resp = await sw.api.get(`/api/v1/teams/${teamId}/members/${memberId}/roles`); + const roles = Array.isArray(resp) ? resp : (resp.data || []); + setMemberRoles(prev => ({ ...prev, [memberId]: roles })); + } catch { /* ignore */ } + } + + async function addExtraRole(memberId, role) { + try { + await sw.api.post(`/api/v1/teams/${teamId}/members/${memberId}/roles`, { role }); + sw.toast('Role assigned', 'success'); + loadMemberRoles(memberId); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function removeExtraRole(memberId, role) { + try { + await sw.api.del(`/api/v1/teams/${teamId}/members/${memberId}/roles/${role}`); + sw.toast('Role removed', 'success'); + loadMemberRoles(memberId); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // Load additional roles for each member once members are loaded + useEffect(() => { + members.forEach(m => { if (m.id) loadMemberRoles(m.id); }); + }, [members]); + async function removeMember(memberId) { const ok = await sw.confirm('Remove this member from the team?', true); if (!ok) return; @@ -106,16 +138,35 @@ export default function MembersSection({ teamId }) {