Feat v0.9.3 team user roles
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m49s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Successful in 1m19s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m49s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Successful in 1m19s
Promote team roles to a kernel primitive with many-to-many support. Users can now hold multiple roles within a team simultaneously. - Migration 016: team_user_roles table (both dialects) - 6 new TeamStore methods (AddUserRole, RemoveUserRole, ListUserRoles, GetMemberRoles, HasRole, RemoveAllUserRoles) - RequireRole() middleware with OR semantics and system admin bypass - 3 new handler endpoints for member role CRUD - Manifest requires_roles field (advisory for v0.9.3) - Starlark teams module: get_member_roles(), has_role() - Team-admin UI: role badge chips + assignment dropdown - Fixed pre-existing SDK auto-unwrap bug in loadRoles - 10 new tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
CHANGELOG.md
54
CHANGELOG.md
@@ -2,6 +2,60 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
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
|
## v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup
|
||||||
|
|
||||||
Consolidates duplicated Go↔Starlark conversion code and snapshot
|
Consolidates duplicated Go↔Starlark conversion code and snapshot
|
||||||
|
|||||||
10
ROADMAP.md
10
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.
|
Standardized on wrapped snapshot format. ~350 lines of duplication removed.
|
||||||
Design doc: `docs/DESIGN-workflow-redesign.md`.
|
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
|
Many-to-many `team_user_roles` table. `RequireRole()` middleware.
|
||||||
`team_user_roles` table (many-to-many). Manifest `requires_roles` field.
|
Manifest `requires_roles` field (advisory). Starlark `teams` module
|
||||||
Team admin UI for role assignment. Kernel middleware `RequireRole()`.
|
with `get_member_roles()` and `has_role()`. Team-admin UI with role
|
||||||
Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`.
|
badge chips and assignment dropdown. 10 new tests.
|
||||||
|
|
||||||
**v0.9.4 — Package Adoption + Roles**
|
**v0.9.4 — Package Adoption + Roles**
|
||||||
|
|
||||||
|
|||||||
17
server/database/migrations/postgres/016_team_user_roles.sql
Normal file
17
server/database/migrations/postgres/016_team_user_roles.sql
Normal file
@@ -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);
|
||||||
17
server/database/migrations/sqlite/016_team_user_roles.sql
Normal file
17
server/database/migrations/sqlite/016_team_user_roles.sql
Normal file
@@ -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);
|
||||||
@@ -33,6 +33,7 @@ type ManifestInfo struct {
|
|||||||
Signature string // reserved for future package signing
|
Signature string // reserved for future package signing
|
||||||
UserPermissions []string // user-facing permissions declared by the extension
|
UserPermissions []string // user-facing permissions declared by the extension
|
||||||
GatePermission string // if set, checks this user permission before calling on_request
|
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,
|
// 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)
|
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)
|
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||||
|
|
||||||
// ── Type-specific constraints ────────────────────────────────
|
// ── Type-specific constraints ────────────────────────────────
|
||||||
|
|||||||
273
server/handlers/team_roles_test.go
Normal file
273
server/handlers/team_roles_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -360,6 +360,11 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
|||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
if affectedUID != "" {
|
if affectedUID != "" {
|
||||||
h.notifyAuthChanged(affectedUID, "team_member_removed")
|
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})
|
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 ─────────────────
|
// ── Team Roles API ─────────────────
|
||||||
|
|
||||||
// builtinRoles are always present in every team's role list.
|
// builtinRoles are always present in every team's role list.
|
||||||
|
|||||||
@@ -640,6 +640,9 @@ func main() {
|
|||||||
teamScoped.POST("/members", teams.AddMember)
|
teamScoped.POST("/members", teams.AddMember)
|
||||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
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.GET("/roles", teams.ListRoles)
|
||||||
teamScoped.PUT("/roles", teams.UpdateRoles)
|
teamScoped.PUT("/roles", teams.UpdateRoles)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// RequireTeamMember returns middleware that restricts access to users who
|
||||||
// belong to the team identified by :teamId (any role).
|
// belong to the team identified by :teamId (any role).
|
||||||
// System admins are always allowed through.
|
// System admins are always allowed through.
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ type TeamMember struct {
|
|||||||
Username string `json:"username,omitempty"`
|
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
|
// PLATFORM POLICIES
|
||||||
|
|
||||||
var PolicyDefaults = map[string]string{
|
var PolicyDefaults = map[string]string{
|
||||||
|
|||||||
@@ -454,6 +454,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
// Always available — read-only check against kernel permission data
|
// Always available — read-only check against kernel permission data
|
||||||
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
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.
|
// Allows any starlark package to load declared library dependencies.
|
||||||
if lc != nil {
|
if lc != nil {
|
||||||
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||||
|
|||||||
62
server/sandbox/teams_module.go
Normal file
62
server/sandbox/teams_module.go
Normal file
@@ -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
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -188,6 +188,26 @@ type TeamStore interface {
|
|||||||
|
|
||||||
// MergeSettings merges a JSON string into the team's settings column.
|
// MergeSettings merges a JSON string into the team's settings column.
|
||||||
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -308,3 +308,88 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri
|
|||||||
settingsJSON, teamID)
|
settingsJSON, teamID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -314,3 +314,88 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri
|
|||||||
settingsJSON, teamID)
|
settingsJSON, teamID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function MembersSection({ teamId }) {
|
|||||||
const loadRoles = useCallback(async () => {
|
const loadRoles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`);
|
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']); }
|
} catch { setRoles(['admin', 'member']); }
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
@@ -63,6 +63,38 @@ export default function MembersSection({ teamId }) {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} 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) {
|
async function removeMember(memberId) {
|
||||||
const ok = await sw.confirm('Remove this member from the team?', true);
|
const ok = await sw.confirm('Remove this member from the team?', true);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -106,16 +138,35 @@ export default function MembersSection({ teamId }) {
|
|||||||
|
|
||||||
<div class="admin-list">
|
<div class="admin-list">
|
||||||
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
||||||
${members.map(m => html`
|
${members.map(m => {
|
||||||
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
const mid = m.id || m.user_id;
|
||||||
|
const extra = (memberRoles[mid] || []).filter(r => r !== m.role);
|
||||||
|
const available = roles.filter(r => r !== m.role && !extra.includes(r));
|
||||||
|
return html`
|
||||||
|
<div class="admin-surface-row" key=${mid} style="flex-wrap:wrap;">
|
||||||
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
|
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
|
||||||
<select value=${m.role || 'member'} style="width:100px;"
|
<select value=${m.role || 'member'} style="width:100px;"
|
||||||
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
|
onChange=${e => updateRole(mid, e.target.value)}>
|
||||||
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(mid)}>Remove</button>
|
||||||
|
${extra.length > 0 && html`
|
||||||
|
<div style="width:100%;display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;">
|
||||||
|
${extra.map(r => html`
|
||||||
|
<span key=${r} class="badge" style="cursor:pointer;" title="Click to remove"
|
||||||
|
onClick=${() => removeExtraRole(mid, r)}>${r} \u00d7</span>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${available.length > 0 && html`
|
||||||
|
<select style="width:auto;margin-top:4px;font-size:12px;"
|
||||||
|
onChange=${e => { if (e.target.value) { addExtraRole(mid, e.target.value); e.target.value = ''; } }}>
|
||||||
|
<option value="">+ Role\u2026</option>
|
||||||
|
${available.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
|
</select>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
|
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user