Feat v0.9.3 team user roles (#77)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / build-and-deploy (push) Successful in 27s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #77.
This commit is contained in:
2026-04-03 15:51:31 +00:00
committed by xcaliber
parent 0cae963480
commit 0661e1d768
17 changed files with 852 additions and 12 deletions

View 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);

View 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);

View File

@@ -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 ────────────────────────────────

View 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)
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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{

View File

@@ -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)

View 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
}),
})
}

View File

@@ -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
}
// =========================================

View File

@@ -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
}

View File

@@ -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
}