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
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:
@@ -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 ────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user