From 067f2e11988b17f0690edd7cb00c629c6e90b82b Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Fri, 21 Nov 2025 17:22:35 -0800 Subject: [PATCH 1/2] wip pagination --- taco/cmd/taco/commands/rbac.go | 1793 +++++++++-------- taco/cmd/taco/commands/unit.go | 964 +++++---- taco/internal/pagination/pagination.go | 52 + .../query/sqlite/initialization_test.go | 80 +- taco/internal/rbac/handler.go | 1744 ++++++++-------- taco/internal/rbac/querystore.go | 85 +- taco/internal/rbac/rbac.go | 74 +- taco/internal/rbac/rbac_test.go | 263 ++- taco/internal/repositories/unit_repository.go | 65 +- taco/internal/token_service/handler.go | 34 +- taco/internal/token_service/repository.go | 29 +- taco/internal/unit/handler.go | 139 +- taco/pkg/sdk/client.go | 358 ++-- taco/pkg/sdk/client_test.go | 110 +- ui/src/api/statesman_serverFunctions.ts | 12 +- ui/src/api/statesman_units.ts | 10 +- ui/src/api/tokens.ts | 9 +- ui/src/api/tokens_serverFunctions.ts | 8 +- .../_dashboard/dashboard/settings.tokens.tsx | 107 +- .../_dashboard/dashboard/units.index.tsx | 60 +- 20 files changed, 3274 insertions(+), 2722 deletions(-) create mode 100644 taco/internal/pagination/pagination.go diff --git a/taco/cmd/taco/commands/rbac.go b/taco/cmd/taco/commands/rbac.go index 5b237718a..cef9801cf 100644 --- a/taco/cmd/taco/commands/rbac.go +++ b/taco/cmd/taco/commands/rbac.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "text/tabwriter" @@ -17,994 +18,1058 @@ import ( // Local types for RBAC CLI (avoid importing internal packages) type UserAssignment struct { - Subject string `json:"subject"` - Email string `json:"email"` - Roles []string `json:"roles"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Subject string `json:"subject"` + Email string `json:"email"` + Roles []string `json:"roles"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type Role struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` - CreatedAt string `json:"created_at"` - CreatedBy string `json:"created_by"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` } type Permission struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Rules []PermissionRule `json:"rules"` - CreatedAt string `json:"created_at"` - CreatedBy string `json:"created_by"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Rules []PermissionRule `json:"rules"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` } type PermissionRule struct { - Actions []string `json:"actions"` - Resources []string `json:"resources"` - Effect string `json:"effect"` + Actions []string `json:"actions"` + Resources []string `json:"resources"` + Effect string `json:"effect"` } +type paginatedRolesResponse struct { + Roles []Role `json:"roles"` + Count int `json:"count"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type paginatedPermissionsResponse struct { + Permissions []Permission `json:"permissions"` + Count int `json:"count"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +var ( + rbacRoleListPage = 1 + rbacRoleListPageSize = 50 + rbacPermissionListPage = 1 + rbacPermissionListSize = 50 +) + // rbacCmd represents the rbac command var rbacCmd = &cobra.Command{ - Use: "rbac", - Short: "Manage RBAC (Role-Based Access Control)", - Long: `Manage RBAC including initialization, role management, and user assignments.`, + Use: "rbac", + Short: "Manage RBAC (Role-Based Access Control)", + Long: `Manage RBAC including initialization, role management, and user assignments.`, } func init() { - rootCmd.AddCommand(rbacCmd) - - // Add subcommands - rbacCmd.AddCommand(rbacInitCmd) - rbacCmd.AddCommand(rbacMeCmd) - rbacCmd.AddCommand(rbacUserCmd) - rbacCmd.AddCommand(rbacRoleCmd) - rbacCmd.AddCommand(rbacPermissionCmd) - rbacCmd.AddCommand(rbacTestCmd) + rootCmd.AddCommand(rbacCmd) + + // Add subcommands + rbacCmd.AddCommand(rbacInitCmd) + rbacCmd.AddCommand(rbacMeCmd) + rbacCmd.AddCommand(rbacUserCmd) + rbacCmd.AddCommand(rbacRoleCmd) + rbacCmd.AddCommand(rbacPermissionCmd) + rbacCmd.AddCommand(rbacTestCmd) } // rbac init command var rbacInitCmd = &cobra.Command{ - Use: "init", - Short: "Initialize RBAC system", - Long: `Initialize RBAC system for the current user. Creates default policies and roles, assigns admin role to current user.`, - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - // Get current user info - userInfo, err := getCurrentUserInfo() - if err != nil { - return fmt.Errorf("failed to get current user info: %w", err) - } - - printVerbose("Initializing RBAC for user: %s (%s)", userInfo.Subject, userInfo.Email) - - // Call RBAC init endpoint - req := map[string]string{ - "subject": userInfo.Subject, - "email": userInfo.Email, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/init", req) - if err != nil { - return fmt.Errorf("failed to initialize RBAC: %w", err) - } - - if resp.StatusCode != 200 { - // Try to parse error response - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("RBAC initialization failed with status %d", resp.StatusCode) - } - - var errorResp struct { - Error string `json:"error"` - Message string `json:"message"` - } - - if err := json.Unmarshal(body, &errorResp); err == nil { - if errorResp.Message != "" { - return fmt.Errorf("%s: %s", errorResp.Error, errorResp.Message) - } - return fmt.Errorf("%s", errorResp.Error) - } - - return fmt.Errorf("RBAC initialization failed with status %d", resp.StatusCode) - } - - fmt.Println("RBAC system initialized successfully!") - fmt.Printf("Admin role assigned to: %s (%s)\n", userInfo.Email, userInfo.Subject) - fmt.Println("Default permissions and roles created.") - - return nil - }, + Use: "init", + Short: "Initialize RBAC system", + Long: `Initialize RBAC system for the current user. Creates default policies and roles, assigns admin role to current user.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + // Get current user info + userInfo, err := getCurrentUserInfo() + if err != nil { + return fmt.Errorf("failed to get current user info: %w", err) + } + + printVerbose("Initializing RBAC for user: %s (%s)", userInfo.Subject, userInfo.Email) + + // Call RBAC init endpoint + req := map[string]string{ + "subject": userInfo.Subject, + "email": userInfo.Email, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/init", req) + if err != nil { + return fmt.Errorf("failed to initialize RBAC: %w", err) + } + + if resp.StatusCode != 200 { + // Try to parse error response + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("RBAC initialization failed with status %d", resp.StatusCode) + } + + var errorResp struct { + Error string `json:"error"` + Message string `json:"message"` + } + + if err := json.Unmarshal(body, &errorResp); err == nil { + if errorResp.Message != "" { + return fmt.Errorf("%s: %s", errorResp.Error, errorResp.Message) + } + return fmt.Errorf("%s", errorResp.Error) + } + + return fmt.Errorf("RBAC initialization failed with status %d", resp.StatusCode) + } + + fmt.Println("RBAC system initialized successfully!") + fmt.Printf("Admin role assigned to: %s (%s)\n", userInfo.Email, userInfo.Subject) + fmt.Println("Default permissions and roles created.") + + return nil + }, } // rbac me command var rbacMeCmd = &cobra.Command{ - Use: "me", - Short: "Show current user's RBAC information", - Long: `Show current user's roles, permissions, and RBAC status.`, - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - printVerbose("Getting RBAC information for current user") - - resp, err := client.Get(context.Background(), "/v1/rbac/me") - if err != nil { - return fmt.Errorf("failed to get RBAC info: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to get RBAC info with status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var data map[string]interface{} - if err := json.Unmarshal(body, &data); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - // Pretty print the response - jsonData, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(jsonData)) - - return nil - }, + Use: "me", + Short: "Show current user's RBAC information", + Long: `Show current user's roles, permissions, and RBAC status.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + printVerbose("Getting RBAC information for current user") + + resp, err := client.Get(context.Background(), "/v1/rbac/me") + if err != nil { + return fmt.Errorf("failed to get RBAC info: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to get RBAC info with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Pretty print the response + jsonData, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(jsonData)) + + return nil + }, } // rbac user command var rbacUserCmd = &cobra.Command{ - Use: "user", - Short: "Manage user role assignments", - Long: `Manage user role assignments including assign and revoke operations.`, + Use: "user", + Short: "Manage user role assignments", + Long: `Manage user role assignments including assign and revoke operations.`, } func init() { - rbacUserCmd.AddCommand(rbacUserAssignCmd) - rbacUserCmd.AddCommand(rbacUserRevokeCmd) - rbacUserCmd.AddCommand(rbacUserListCmd) + rbacUserCmd.AddCommand(rbacUserAssignCmd) + rbacUserCmd.AddCommand(rbacUserRevokeCmd) + rbacUserCmd.AddCommand(rbacUserListCmd) } // rbac user assign command var rbacUserAssignCmd = &cobra.Command{ - Use: "assign ", - Short: "Assign a role to a user", - Long: `Assign a role to a user by email address. The user must have logged in at least once to be found in the system.`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - email := args[0] - roleID := mustResolveRoleID(context.Background(), client, args[1]) - - printVerbose("Assigning role %s to user %s", roleID, email) - - req := map[string]string{ - "email": email, - "role_id": roleID, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/users/assign", req) - if err != nil { - return fmt.Errorf("failed to assign role: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("role assignment failed with status %d", resp.StatusCode) - } - - fmt.Printf("Role %s assigned to %s\n", roleID, email) - return nil - }, + Use: "assign ", + Short: "Assign a role to a user", + Long: `Assign a role to a user by email address. The user must have logged in at least once to be found in the system.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + email := args[0] + roleID := mustResolveRoleID(context.Background(), client, args[1]) + + printVerbose("Assigning role %s to user %s", roleID, email) + + req := map[string]string{ + "email": email, + "role_id": roleID, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/users/assign", req) + if err != nil { + return fmt.Errorf("failed to assign role: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("role assignment failed with status %d", resp.StatusCode) + } + + fmt.Printf("Role %s assigned to %s\n", roleID, email) + return nil + }, } // rbac user revoke command var rbacUserRevokeCmd = &cobra.Command{ - Use: "revoke ", - Short: "Revoke a role from a user", - Long: `Revoke a role from a user by email address.`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - email := args[0] - roleID := mustResolveRoleID(context.Background(), client, args[1]) - - printVerbose("Revoking role %s from user %s", roleID, email) - - req := map[string]string{ - "email": email, - "role_id": roleID, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/users/revoke", req) - if err != nil { - return fmt.Errorf("failed to revoke role: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("role revocation failed with status %d", resp.StatusCode) - } - - fmt.Printf("Role %s revoked from %s\n", roleID, email) - return nil - }, + Use: "revoke ", + Short: "Revoke a role from a user", + Long: `Revoke a role from a user by email address.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + email := args[0] + roleID := mustResolveRoleID(context.Background(), client, args[1]) + + printVerbose("Revoking role %s from user %s", roleID, email) + + req := map[string]string{ + "email": email, + "role_id": roleID, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/users/revoke", req) + if err != nil { + return fmt.Errorf("failed to revoke role: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("role revocation failed with status %d", resp.StatusCode) + } + + fmt.Printf("Role %s revoked from %s\n", roleID, email) + return nil + }, } // rbac user list command var rbacUserListCmd = &cobra.Command{ - Use: "list", - Short: "List all user role assignments", - Long: `List all user role assignments in the system.`, - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - printVerbose("Listing all user role assignments") - - resp, err := client.Get(context.Background(), "/v1/rbac/users") - if err != nil { - return fmt.Errorf("failed to list user assignments: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to list user assignments with status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var assignments []UserAssignment - if err := json.Unmarshal(body, &assignments); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if len(assignments) == 0 { - fmt.Println("No user assignments found") - return nil - } - - // Create tabwriter - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "SUBJECT\tEMAIL\tROLES\tUPDATED") - - for _, assignment := range assignments { - roles := strings.Join(assignment.Roles, ", ") - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - assignment.Subject, - assignment.Email, - roles, - assignment.UpdatedAt, - ) - } - - w.Flush() - fmt.Printf("\nTotal: %d user assignments\n", len(assignments)) - - return nil - }, + Use: "list", + Short: "List all user role assignments", + Long: `List all user role assignments in the system.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + printVerbose("Listing all user role assignments") + + resp, err := client.Get(context.Background(), "/v1/rbac/users") + if err != nil { + return fmt.Errorf("failed to list user assignments: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to list user assignments with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var assignments []UserAssignment + if err := json.Unmarshal(body, &assignments); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if len(assignments) == 0 { + fmt.Println("No user assignments found") + return nil + } + + // Create tabwriter + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "SUBJECT\tEMAIL\tROLES\tUPDATED") + + for _, assignment := range assignments { + roles := strings.Join(assignment.Roles, ", ") + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + assignment.Subject, + assignment.Email, + roles, + assignment.UpdatedAt, + ) + } + + w.Flush() + fmt.Printf("\nTotal: %d user assignments\n", len(assignments)) + + return nil + }, } // rbac role command var rbacRoleCmd = &cobra.Command{ - Use: "role", - Short: "Manage roles", - Long: `Manage roles including create, list, and delete operations.`, + Use: "role", + Short: "Manage roles", + Long: `Manage roles including create, list, and delete operations.`, } func init() { - rbacRoleCmd.AddCommand(rbacRoleCreateCmd) - rbacRoleCmd.AddCommand(rbacRoleListCmd) - rbacRoleCmd.AddCommand(rbacRoleDeleteCmd) - rbacRoleCmd.AddCommand(rbacRoleAssignPolicyCmd) - rbacRoleCmd.AddCommand(rbacRoleRevokePermissionCmd) + rbacRoleCmd.AddCommand(rbacRoleCreateCmd) + rbacRoleCmd.AddCommand(rbacRoleListCmd) + rbacRoleCmd.AddCommand(rbacRoleDeleteCmd) + rbacRoleCmd.AddCommand(rbacRoleAssignPolicyCmd) + rbacRoleCmd.AddCommand(rbacRoleRevokePermissionCmd) + rbacRoleListCmd.Flags().IntVar(&rbacRoleListPage, "page", 1, "Page number for roles") + rbacRoleListCmd.Flags().IntVar(&rbacRoleListPageSize, "page-size", 50, "Roles per page") } // rbac role create command var rbacRoleCreateCmd = &cobra.Command{ - Use: "create ", - Short: "Create a new role", - Long: `Create a new role with the specified ID, name, and description.`, - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - roleID := args[0] - name := args[1] - description := args[2] - - printVerbose("Creating role %s: %s", roleID, name) - - req := map[string]interface{}{ - "id": roleID, - "name": name, - "description": description, - "permissions": []string{}, // Empty permissions for now - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/roles", req) - if err != nil { - return fmt.Errorf("failed to create role: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("role creation failed with status %d", resp.StatusCode) - } - - fmt.Printf("Role %s created successfully: %s\n", roleID, name) - return nil - }, + Use: "create ", + Short: "Create a new role", + Long: `Create a new role with the specified ID, name, and description.`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + roleID := args[0] + name := args[1] + description := args[2] + + printVerbose("Creating role %s: %s", roleID, name) + + req := map[string]interface{}{ + "id": roleID, + "name": name, + "description": description, + "permissions": []string{}, // Empty permissions for now + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/roles", req) + if err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("role creation failed with status %d", resp.StatusCode) + } + + fmt.Printf("Role %s created successfully: %s\n", roleID, name) + return nil + }, } // rbac role list command var rbacRoleListCmd = &cobra.Command{ - Use: "list", - Short: "List all roles", - Long: `List all roles in the system.`, - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - printVerbose("Listing all roles") - - resp, err := client.Get(context.Background(), "/v1/rbac/roles") - if err != nil { - return fmt.Errorf("failed to list roles: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to list roles with status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var roles []Role - if err := json.Unmarshal(body, &roles); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if len(roles) == 0 { - fmt.Println("No roles found") - return nil - } - - // Create tabwriter - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tDESCRIPTION\tPERMISSIONS\tCREATED") - - for _, role := range roles { - permissions := strings.Join(role.Permissions, ", ") - name := role.Name - if name == "" { name = role.ID } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - name, - role.Description, - permissions, - role.CreatedAt, - ) - } - - w.Flush() - fmt.Printf("\nTotal: %d roles\n", len(roles)) - - return nil - }, + Use: "list", + Short: "List all roles", + Long: `List all roles in the system.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + page := rbacRoleListPage + if page < 1 { + page = 1 + } + pageSize := rbacRoleListPageSize + if pageSize < 1 { + pageSize = 50 + } + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("page_size", strconv.Itoa(pageSize)) + + path := "/v1/rbac/roles" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + printVerbose("Listing roles (page %d, size %d)", page, pageSize) + + resp, err := client.Get(context.Background(), path) + if err != nil { + return fmt.Errorf("failed to list roles: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to list roles with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var rolesPage paginatedRolesResponse + if err := json.Unmarshal(body, &rolesPage); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if len(rolesPage.Roles) == 0 { + fmt.Println("No roles found") + return nil + } + + // Create tabwriter + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tPERMISSIONS\tCREATED") + + for _, role := range rolesPage.Roles { + permissions := strings.Join(role.Permissions, ", ") + name := role.Name + if name == "" { + name = role.ID + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + name, + role.Description, + permissions, + role.CreatedAt, + ) + } + + w.Flush() + fmt.Printf("\nPage %d (size %d) — showing %d of %d roles\n", rolesPage.Page, rolesPage.PageSize, len(rolesPage.Roles), rolesPage.Total) + + return nil + }, } // rbac role delete command var rbacRoleDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a role", - Long: `Delete a role by name.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - roleID := mustResolveRoleID(context.Background(), client, args[0]) - - printVerbose("Deleting role %s", roleID) - - resp, err := client.Delete(context.Background(), "/v1/rbac/roles/"+roleID) - if err != nil { - return fmt.Errorf("failed to delete role: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("role deletion failed with status %d", resp.StatusCode) - } - - fmt.Printf("Role %s deleted successfully\n", roleID) - return nil - }, + Use: "delete ", + Short: "Delete a role", + Long: `Delete a role by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + roleID := mustResolveRoleID(context.Background(), client, args[0]) + + printVerbose("Deleting role %s", roleID) + + resp, err := client.Delete(context.Background(), "/v1/rbac/roles/"+roleID) + if err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("role deletion failed with status %d", resp.StatusCode) + } + + fmt.Printf("Role %s deleted successfully\n", roleID) + return nil + }, } // Helper function to get current user info func getCurrentUserInfo() (*UserInfo, error) { - base := normalizedBase(serverURL) - cf, err := loadCreds() - if err != nil { - return nil, err - } - - tok, ok := cf.Profiles[base] - if !ok || tok.AccessToken == "" { - // Fallback: if only one profile exists, use it - if len(cf.Profiles) == 1 { - for _, t := range cf.Profiles { - tok = t - ok = true - break - } - } - if !ok || tok.AccessToken == "" { - return nil, fmt.Errorf("not logged in; run 'taco login' first") - } - } - - // Get user info from /v1/auth/me - req, _ := http.NewRequest("GET", base+"/v1/auth/me", nil) - req.Header.Set("Authorization", "Bearer "+tok.AccessToken) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to get user info: HTTP %d", resp.StatusCode) - } - - var data map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, err - } - - subject, _ := data["subject"].(string) - email := "" - - // Try to extract email from various possible fields - if emailVal, ok := data["email"].(string); ok { - email = emailVal - } else if emailVal, ok := data["preferred_username"].(string); ok { - email = emailVal - } else if emailVal, ok := data["sub"].(string); ok && strings.Contains(emailVal, "@") { - email = emailVal - } - - return &UserInfo{ - Subject: subject, - Email: email, - }, nil + base := normalizedBase(serverURL) + cf, err := loadCreds() + if err != nil { + return nil, err + } + + tok, ok := cf.Profiles[base] + if !ok || tok.AccessToken == "" { + // Fallback: if only one profile exists, use it + if len(cf.Profiles) == 1 { + for _, t := range cf.Profiles { + tok = t + ok = true + break + } + } + if !ok || tok.AccessToken == "" { + return nil, fmt.Errorf("not logged in; run 'taco login' first") + } + } + + // Get user info from /v1/auth/me + req, _ := http.NewRequest("GET", base+"/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get user info: HTTP %d", resp.StatusCode) + } + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + subject, _ := data["subject"].(string) + email := "" + + // Try to extract email from various possible fields + if emailVal, ok := data["email"].(string); ok { + email = emailVal + } else if emailVal, ok := data["preferred_username"].(string); ok { + email = emailVal + } else if emailVal, ok := data["sub"].(string); ok && strings.Contains(emailVal, "@") { + email = emailVal + } + + return &UserInfo{ + Subject: subject, + Email: email, + }, nil } // UserInfo represents basic user information type UserInfo struct { - Subject string - Email string + Subject string + Email string } // rbac permission command var rbacPermissionCmd = &cobra.Command{ - Use: "permission", - Short: "Manage RBAC permissions", - Long: `Manage RBAC permissions that define access rights for roles.`, + Use: "permission", + Short: "Manage RBAC permissions", + Long: `Manage RBAC permissions that define access rights for roles.`, } func init() { - rbacPermissionCmd.AddCommand(rbacPermissionCreateCmd) - rbacPermissionCmd.AddCommand(rbacPermissionListCmd) - rbacPermissionCmd.AddCommand(rbacPermissionDeleteCmd) + rbacPermissionCmd.AddCommand(rbacPermissionCreateCmd) + rbacPermissionCmd.AddCommand(rbacPermissionListCmd) + rbacPermissionCmd.AddCommand(rbacPermissionDeleteCmd) + rbacPermissionListCmd.Flags().IntVar(&rbacPermissionListPage, "page", 1, "Page number for permissions") + rbacPermissionListCmd.Flags().IntVar(&rbacPermissionListSize, "page-size", 50, "Permissions per page") } // rbac permission create command var rbacPermissionCreateCmd = &cobra.Command{ - Use: "create ", - Short: "Create a new permission", - Long: `Create a new permission with specified rules. Use --rule flag to add rules.`, - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - id := args[0] - name := args[1] - description := args[2] - - // Get rules from flags - rules, _ := cmd.Flags().GetStringArray("rule") - - client := newAuthedClient() - - // Parse rules - var permissionRules []PermissionRule - for _, ruleStr := range rules { - // Format: "effect:actions:resources" - // Example: "allow:unit.read,unit.write:dev/*" - parts := strings.Split(ruleStr, ":") - if len(parts) != 3 { - return fmt.Errorf("invalid rule format: %s. Expected: effect:actions:resources", ruleStr) - } - - effect := parts[0] - actions := strings.Split(parts[1], ",") - resources := strings.Split(parts[2], ",") - - permissionRules = append(permissionRules, PermissionRule{ - Actions: actions, - Resources: resources, - Effect: effect, - }) - } - - req := map[string]interface{}{ - "id": id, - "name": name, - "description": description, - "rules": permissionRules, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/permissions", req) - if err != nil { - return fmt.Errorf("failed to create permission: %w", err) - } - - if resp.StatusCode != 201 { - return fmt.Errorf("failed to create permission with status %d", resp.StatusCode) - } - - fmt.Printf("Permission '%s' created successfully\n", id) - return nil - }, + Use: "create ", + Short: "Create a new permission", + Long: `Create a new permission with specified rules. Use --rule flag to add rules.`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + name := args[1] + description := args[2] + + // Get rules from flags + rules, _ := cmd.Flags().GetStringArray("rule") + + client := newAuthedClient() + + // Parse rules + var permissionRules []PermissionRule + for _, ruleStr := range rules { + // Format: "effect:actions:resources" + // Example: "allow:unit.read,unit.write:dev/*" + parts := strings.Split(ruleStr, ":") + if len(parts) != 3 { + return fmt.Errorf("invalid rule format: %s. Expected: effect:actions:resources", ruleStr) + } + + effect := parts[0] + actions := strings.Split(parts[1], ",") + resources := strings.Split(parts[2], ",") + + permissionRules = append(permissionRules, PermissionRule{ + Actions: actions, + Resources: resources, + Effect: effect, + }) + } + + req := map[string]interface{}{ + "id": id, + "name": name, + "description": description, + "rules": permissionRules, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/permissions", req) + if err != nil { + return fmt.Errorf("failed to create permission: %w", err) + } + + if resp.StatusCode != 201 { + return fmt.Errorf("failed to create permission with status %d", resp.StatusCode) + } + + fmt.Printf("Permission '%s' created successfully\n", id) + return nil + }, } // rbac permission list command var rbacPermissionListCmd = &cobra.Command{ - Use: "list", - Short: "List all permissions", - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - resp, err := client.Get(context.Background(), "/v1/rbac/permissions") - if err != nil { - return fmt.Errorf("failed to list permissions: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to list permissions with status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var permissions []Permission - if err := json.Unmarshal(body, &permissions); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if len(permissions) == 0 { - fmt.Println("No permissions found") - return nil - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tDESCRIPTION\tRULES\tCREATED") - - for _, permission := range permissions { - rules := "" - for i, rule := range permission.Rules { - if i > 0 { - rules += "; " - } - rules += fmt.Sprintf("%s:%s:%s", rule.Effect, strings.Join(rule.Actions, ","), strings.Join(rule.Resources, ",")) - } - - name := permission.Name - if name == "" { name = permission.ID } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - name, - permission.Description, - rules, - permission.CreatedAt, - ) - } - - w.Flush() - fmt.Printf("\nTotal: %d permissions\n", len(permissions)) - return nil - }, + Use: "list", + Short: "List all permissions", + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + page := rbacPermissionListPage + if page < 1 { + page = 1 + } + pageSize := rbacPermissionListSize + if pageSize < 1 { + pageSize = 50 + } + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("page_size", strconv.Itoa(pageSize)) + + path := "/v1/rbac/permissions" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + + resp, err := client.Get(context.Background(), path) + if err != nil { + return fmt.Errorf("failed to list permissions: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to list permissions with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var permissionsPage paginatedPermissionsResponse + if err := json.Unmarshal(body, &permissionsPage); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if len(permissionsPage.Permissions) == 0 { + fmt.Println("No permissions found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tRULES\tCREATED") + + for _, permission := range permissionsPage.Permissions { + rules := "" + for i, rule := range permission.Rules { + if i > 0 { + rules += "; " + } + rules += fmt.Sprintf("%s:%s:%s", rule.Effect, strings.Join(rule.Actions, ","), strings.Join(rule.Resources, ",")) + } + + name := permission.Name + if name == "" { + name = permission.ID + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + name, + permission.Description, + rules, + permission.CreatedAt, + ) + } + + w.Flush() + fmt.Printf("\nPage %d (size %d) — showing %d of %d permissions\n", permissionsPage.Page, permissionsPage.PageSize, len(permissionsPage.Permissions), permissionsPage.Total) + return nil + }, } // rbac permission delete command var rbacPermissionDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a permission", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - id := mustResolvePermissionID(context.Background(), client, args[0]) - - resp, err := client.Delete(context.Background(), "/v1/rbac/permissions/"+id) - if err != nil { - return fmt.Errorf("failed to delete permission: %w", err) - } - - if resp.StatusCode != 204 { - return fmt.Errorf("failed to delete permission with status %d", resp.StatusCode) - } - - fmt.Printf("Permission '%s' deleted successfully\n", id) - return nil - }, + Use: "delete ", + Short: "Delete a permission", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + id := mustResolvePermissionID(context.Background(), client, args[0]) + + resp, err := client.Delete(context.Background(), "/v1/rbac/permissions/"+id) + if err != nil { + return fmt.Errorf("failed to delete permission: %w", err) + } + + if resp.StatusCode != 204 { + return fmt.Errorf("failed to delete permission with status %d", resp.StatusCode) + } + + fmt.Printf("Permission '%s' deleted successfully\n", id) + return nil + }, } func init() { - rbacPermissionCreateCmd.Flags().StringArray("rule", []string{}, "Permission rule in format: effect:actions:resources (e.g., allow:state.read,state.write:dev/*)") + rbacPermissionCreateCmd.Flags().StringArray("rule", []string{}, "Permission rule in format: effect:actions:resources (e.g., allow:state.read,state.write:dev/*)") } // rbac test command var rbacTestCmd = &cobra.Command{ - Use: "test [args...]", - Short: "Test RBAC permissions for a user without executing operations", - Long: `Test what operations a user would be able to perform based on their RBAC roles and permissions.`, - Args: cobra.MinimumNArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - email := args[0] - operation := args[1] - operationArgs := args[2:] - - client := newAuthedClient() - - // Test the operation - result, err := testUserOperation(client, email, operation, operationArgs) - if err != nil { - return fmt.Errorf("failed to test operation: %w", err) - } - - // Display results - fmt.Printf("Testing operation for user: %s\n", email) - fmt.Printf("Operation: %s %s\n", operation, strings.Join(operationArgs, " ")) - fmt.Printf("Result: %s\n", result.Status) - if result.Reason != "" { - fmt.Printf("Reason: %s\n", result.Reason) - } - if len(result.UserRoles) > 0 { - fmt.Printf("User roles: %s\n", strings.Join(result.UserRoles, ", ")) - } - if len(result.ApplicablePermissions) > 0 { - fmt.Printf("Applicable permissions: %s\n", strings.Join(result.ApplicablePermissions, ", ")) - } - - return nil - }, + Use: "test [args...]", + Short: "Test RBAC permissions for a user without executing operations", + Long: `Test what operations a user would be able to perform based on their RBAC roles and permissions.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + email := args[0] + operation := args[1] + operationArgs := args[2:] + + client := newAuthedClient() + + // Test the operation + result, err := testUserOperation(client, email, operation, operationArgs) + if err != nil { + return fmt.Errorf("failed to test operation: %w", err) + } + + // Display results + fmt.Printf("Testing operation for user: %s\n", email) + fmt.Printf("Operation: %s %s\n", operation, strings.Join(operationArgs, " ")) + fmt.Printf("Result: %s\n", result.Status) + if result.Reason != "" { + fmt.Printf("Reason: %s\n", result.Reason) + } + if len(result.UserRoles) > 0 { + fmt.Printf("User roles: %s\n", strings.Join(result.UserRoles, ", ")) + } + if len(result.ApplicablePermissions) > 0 { + fmt.Printf("Applicable permissions: %s\n", strings.Join(result.ApplicablePermissions, ", ")) + } + + return nil + }, } // TestResult represents the result of a permission test type TestResult struct { - Status string `json:"status"` // "allowed", "denied", "error" - Reason string `json:"reason"` // Explanation of the result - UserRoles []string `json:"user_roles"` // Roles assigned to the user - ApplicablePermissions []string `json:"applicable_permissions"` // Permissions that apply to this operation + Status string `json:"status"` // "allowed", "denied", "error" + Reason string `json:"reason"` // Explanation of the result + UserRoles []string `json:"user_roles"` // Roles assigned to the user + ApplicablePermissions []string `json:"applicable_permissions"` // Permissions that apply to this operation } // testUserOperation tests what a user can do for a given operation func testUserOperation(client *sdk.Client, email, operation string, args []string) (*TestResult, error) { - // Map operations to actions and resources - var action, resource string - - switch operation { - case "lock": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "lock operation requires unit ID"}, nil - } - action = "unit.lock" - resource = args[0] - case "unlock": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "unlock operation requires unit ID"}, nil - } - action = "unit.lock" // Same permission as lock - resource = args[0] - case "assign": - if len(args) < 2 { - return &TestResult{Status: "error", Reason: "assign operation requires role ID"}, nil - } - action = "rbac.manage" - resource = "rbac.users" - case "revoke": - if len(args) < 2 { - return &TestResult{Status: "error", Reason: "revoke operation requires role ID"}, nil - } - action = "rbac.manage" - resource = "rbac.users" - case "unit", "push": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "push operation requires unit ID"}, nil - } - action = "unit.write" - resource = args[0] - case "pull": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "pull operation requires unit ID"}, nil - } - action = "unit.read" - resource = args[0] - case "create": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "create operation requires unit ID"}, nil - } - action = "unit.write" - resource = args[0] - case "delete", "rm": - if len(args) < 1 { - return &TestResult{Status: "error", Reason: "delete operation requires unit ID"}, nil - } - action = "unit.delete" - resource = args[0] - case "ls", "list": - action = "unit.read" - resource = "*" // List operation checks read access to all units - case "ls-output": - // Special case: show actual filtered list output - return testUserListOutput(client, email, args) - default: - return &TestResult{Status: "error", Reason: fmt.Sprintf("unknown operation: %s", operation)}, nil - } - - // Call the test endpoint - req := map[string]interface{}{ - "email": email, - "action": action, - "resource": resource, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/test", req) - if err != nil { - return nil, fmt.Errorf("failed to test permissions: %w", err) - } - - if resp.StatusCode != 200 { - return &TestResult{Status: "error", Reason: fmt.Sprintf("test failed with status %d", resp.StatusCode)}, nil - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var result TestResult - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &result, nil + // Map operations to actions and resources + var action, resource string + + switch operation { + case "lock": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "lock operation requires unit ID"}, nil + } + action = "unit.lock" + resource = args[0] + case "unlock": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "unlock operation requires unit ID"}, nil + } + action = "unit.lock" // Same permission as lock + resource = args[0] + case "assign": + if len(args) < 2 { + return &TestResult{Status: "error", Reason: "assign operation requires role ID"}, nil + } + action = "rbac.manage" + resource = "rbac.users" + case "revoke": + if len(args) < 2 { + return &TestResult{Status: "error", Reason: "revoke operation requires role ID"}, nil + } + action = "rbac.manage" + resource = "rbac.users" + case "unit", "push": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "push operation requires unit ID"}, nil + } + action = "unit.write" + resource = args[0] + case "pull": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "pull operation requires unit ID"}, nil + } + action = "unit.read" + resource = args[0] + case "create": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "create operation requires unit ID"}, nil + } + action = "unit.write" + resource = args[0] + case "delete", "rm": + if len(args) < 1 { + return &TestResult{Status: "error", Reason: "delete operation requires unit ID"}, nil + } + action = "unit.delete" + resource = args[0] + case "ls", "list": + action = "unit.read" + resource = "*" // List operation checks read access to all units + case "ls-output": + // Special case: show actual filtered list output + return testUserListOutput(client, email, args) + default: + return &TestResult{Status: "error", Reason: fmt.Sprintf("unknown operation: %s", operation)}, nil + } + + // Call the test endpoint + req := map[string]interface{}{ + "email": email, + "action": action, + "resource": resource, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/test", req) + if err != nil { + return nil, fmt.Errorf("failed to test permissions: %w", err) + } + + if resp.StatusCode != 200 { + return &TestResult{Status: "error", Reason: fmt.Sprintf("test failed with status %d", resp.StatusCode)}, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result TestResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil } // testUserListOutput shows the actual filtered unit list for a user func testUserListOutput(client *sdk.Client, email string, args []string) (*TestResult, error) { - // Get all units from server (skip the problematic * permission check) - prefix := "" - if len(args) > 0 { - prefix = args[0] - } - - unitsResp, err := client.Get(context.Background(), "/v1/units?prefix="+url.QueryEscape(prefix)) - if err != nil { - return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to fetch units: %v", err)}, nil - } - defer unitsResp.Body.Close() - - if unitsResp.StatusCode != 200 { - return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to fetch units with status %d", unitsResp.StatusCode)}, nil - } - - unitsBody, err := io.ReadAll(unitsResp.Body) - if err != nil { - return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to read units response: %v", err)}, nil - } - - var unitsData struct { - Units []struct { - ID string `json:"id"` - Size int `json:"size"` - Updated string `json:"updated"` - Locked bool `json:"locked"` - } `json:"units"` - } - - if err := json.Unmarshal(unitsBody, &unitsData); err != nil { - return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to parse units response: %v", err)}, nil - } - - // Filter units based on user's read permissions - var accessibleUnits []struct { - ID string `json:"id"` - Size int `json:"size"` - Updated string `json:"updated"` - Locked bool `json:"locked"` - } - - for _, unit := range unitsData.Units { - // Test read permission for this specific unit - unitTestReq := map[string]interface{}{ - "email": email, - "action": "unit.read", - "resource": unit.ID, - } - - unitResp, err := client.PostJSON(context.Background(), "/v1/rbac/test", unitTestReq) - if err != nil { - continue // Skip on error - } - - if unitResp.StatusCode != 200 { - unitResp.Body.Close() - continue - } - - unitBody, err := io.ReadAll(unitResp.Body) - unitResp.Body.Close() - if err != nil { - continue - } - - var unitResult struct { - Status string `json:"status"` - } - if err := json.Unmarshal(unitBody, &unitResult); err != nil { - continue - } - - if unitResult.Status == "allowed" { - accessibleUnits = append(accessibleUnits, unit) - } - } - - // Format the output similar to unit ls command - result := fmt.Sprintf("Units accessible to %s:\n\n", email) - if len(accessibleUnits) == 0 { - result += "No units accessible to this user.\n" - } else { - result += "ID\tSIZE\tUPDATED\tLOCKED\n" - for _, unit := range accessibleUnits { - locked := "" - if unit.Locked { - locked = "yes" - } else { - locked = "no" - } - result += fmt.Sprintf("%s\t%d\t%s\t%s\n", unit.ID, unit.Size, unit.Updated, locked) - } - result += fmt.Sprintf("\nTotal: %d units", len(accessibleUnits)) - } - - return &TestResult{ - Status: "allowed", - Reason: result, - }, nil + // Get all units from server (skip the problematic * permission check) + prefix := "" + if len(args) > 0 { + prefix = args[0] + } + + unitsResp, err := client.Get(context.Background(), "/v1/units?prefix="+url.QueryEscape(prefix)) + if err != nil { + return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to fetch units: %v", err)}, nil + } + defer unitsResp.Body.Close() + + if unitsResp.StatusCode != 200 { + return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to fetch units with status %d", unitsResp.StatusCode)}, nil + } + + unitsBody, err := io.ReadAll(unitsResp.Body) + if err != nil { + return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to read units response: %v", err)}, nil + } + + var unitsData struct { + Units []struct { + ID string `json:"id"` + Size int `json:"size"` + Updated string `json:"updated"` + Locked bool `json:"locked"` + } `json:"units"` + } + + if err := json.Unmarshal(unitsBody, &unitsData); err != nil { + return &TestResult{Status: "error", Reason: fmt.Sprintf("failed to parse units response: %v", err)}, nil + } + + // Filter units based on user's read permissions + var accessibleUnits []struct { + ID string `json:"id"` + Size int `json:"size"` + Updated string `json:"updated"` + Locked bool `json:"locked"` + } + + for _, unit := range unitsData.Units { + // Test read permission for this specific unit + unitTestReq := map[string]interface{}{ + "email": email, + "action": "unit.read", + "resource": unit.ID, + } + + unitResp, err := client.PostJSON(context.Background(), "/v1/rbac/test", unitTestReq) + if err != nil { + continue // Skip on error + } + + if unitResp.StatusCode != 200 { + unitResp.Body.Close() + continue + } + + unitBody, err := io.ReadAll(unitResp.Body) + unitResp.Body.Close() + if err != nil { + continue + } + + var unitResult struct { + Status string `json:"status"` + } + if err := json.Unmarshal(unitBody, &unitResult); err != nil { + continue + } + + if unitResult.Status == "allowed" { + accessibleUnits = append(accessibleUnits, unit) + } + } + + // Format the output similar to unit ls command + result := fmt.Sprintf("Units accessible to %s:\n\n", email) + if len(accessibleUnits) == 0 { + result += "No units accessible to this user.\n" + } else { + result += "ID\tSIZE\tUPDATED\tLOCKED\n" + for _, unit := range accessibleUnits { + locked := "" + if unit.Locked { + locked = "yes" + } else { + locked = "no" + } + result += fmt.Sprintf("%s\t%d\t%s\t%s\n", unit.ID, unit.Size, unit.Updated, locked) + } + result += fmt.Sprintf("\nTotal: %d units", len(accessibleUnits)) + } + + return &TestResult{ + Status: "allowed", + Reason: result, + }, nil } // rbac role assign-policy command var rbacRoleAssignPolicyCmd = &cobra.Command{ - Use: "assign-policy ", - Short: "Assign a policy to a role", - Long: `Assign a policy to a role, giving the role the permissions defined in the policy.`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - roleID := mustResolveRoleID(context.Background(), client, args[0]) - permissionID := mustResolvePermissionID(context.Background(), client, args[1]) - - req := map[string]string{ - "role_id": roleID, - "permission_id": permissionID, - } - - resp, err := client.PostJSON(context.Background(), "/v1/rbac/roles/"+roleID+"/permissions", req) - if err != nil { - return fmt.Errorf("failed to assign permission to role: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to assign permission to role with status %d", resp.StatusCode) - } - - fmt.Printf("Permission '%s' assigned to role '%s' successfully\n", permissionID, roleID) - return nil - }, + Use: "assign-policy ", + Short: "Assign a policy to a role", + Long: `Assign a policy to a role, giving the role the permissions defined in the policy.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + roleID := mustResolveRoleID(context.Background(), client, args[0]) + permissionID := mustResolvePermissionID(context.Background(), client, args[1]) + + req := map[string]string{ + "role_id": roleID, + "permission_id": permissionID, + } + + resp, err := client.PostJSON(context.Background(), "/v1/rbac/roles/"+roleID+"/permissions", req) + if err != nil { + return fmt.Errorf("failed to assign permission to role: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to assign permission to role with status %d", resp.StatusCode) + } + + fmt.Printf("Permission '%s' assigned to role '%s' successfully\n", permissionID, roleID) + return nil + }, } // rbac role revoke-permission command var rbacRoleRevokePermissionCmd = &cobra.Command{ - Use: "revoke-permission ", - Short: "Revoke a permission from a role", - Long: `Revoke a permission from a role, removing the access rights defined in the permission.`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - roleID := mustResolveRoleID(context.Background(), client, args[0]) - permissionID := mustResolvePermissionID(context.Background(), client, args[1]) - - resp, err := client.Delete(context.Background(), "/v1/rbac/roles/"+roleID+"/permissions/"+permissionID) - if err != nil { - return fmt.Errorf("failed to revoke permission from role: %w", err) - } - - if resp.StatusCode != 204 { - return fmt.Errorf("failed to revoke permission from role with status %d", resp.StatusCode) - } - - fmt.Printf("Permission '%s' revoked from role '%s' successfully\n", permissionID, roleID) - return nil - }, + Use: "revoke-permission ", + Short: "Revoke a permission from a role", + Long: `Revoke a permission from a role, removing the access rights defined in the permission.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + roleID := mustResolveRoleID(context.Background(), client, args[0]) + permissionID := mustResolvePermissionID(context.Background(), client, args[1]) + + resp, err := client.Delete(context.Background(), "/v1/rbac/roles/"+roleID+"/permissions/"+permissionID) + if err != nil { + return fmt.Errorf("failed to revoke permission from role: %w", err) + } + + if resp.StatusCode != 204 { + return fmt.Errorf("failed to revoke permission from role with status %d", resp.StatusCode) + } + + fmt.Printf("Permission '%s' revoked from role '%s' successfully\n", permissionID, roleID) + return nil + }, } // mustResolveRoleID resolves a role name to its ID // If the argument is already a valid identifier, it's returned as-is func mustResolveRoleID(ctx context.Context, client *sdk.Client, arg string) string { - resp, err := client.Get(ctx, "/v1/rbac/roles") - if err != nil || resp.StatusCode != 200 { - return arg // fallback - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return arg - } - - var roles []Role - if err := json.Unmarshal(body, &roles); err != nil { - return arg - } - - for _, r := range roles { - if r.Name == arg || r.ID == arg { - if r.ID != "" { - return r.ID - } - return arg - } - } - return arg + resp, err := client.Get(ctx, "/v1/rbac/roles") + if err != nil || resp.StatusCode != 200 { + return arg // fallback + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return arg + } + + var roles []Role + if err := json.Unmarshal(body, &roles); err != nil { + return arg + } + + for _, r := range roles { + if r.Name == arg || r.ID == arg { + if r.ID != "" { + return r.ID + } + return arg + } + } + return arg } // mustResolvePermissionID resolves a permission name to its ID // If the argument is already a valid identifier, it's returned as-is func mustResolvePermissionID(ctx context.Context, client *sdk.Client, arg string) string { - resp, err := client.Get(ctx, "/v1/rbac/permissions") - if err != nil || resp.StatusCode != 200 { - return arg // fallback - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return arg - } - - var permissions []Permission - if err := json.Unmarshal(body, &permissions); err != nil { - return arg - } - - for _, p := range permissions { - if p.Name == arg || p.ID == arg { - if p.ID != "" { - return p.ID - } - return arg - } - } - return arg + resp, err := client.Get(ctx, "/v1/rbac/permissions") + if err != nil || resp.StatusCode != 200 { + return arg // fallback + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return arg + } + + var permissions []Permission + if err := json.Unmarshal(body, &permissions); err != nil { + return arg + } + + for _, p := range permissions { + if p.Name == arg || p.ID == arg { + if p.ID != "" { + return p.ID + } + return arg + } + } + return arg } diff --git a/taco/cmd/taco/commands/unit.go b/taco/cmd/taco/commands/unit.go index b0995d200..fdfce4e0c 100644 --- a/taco/cmd/taco/commands/unit.go +++ b/taco/cmd/taco/commands/unit.go @@ -1,502 +1,634 @@ package commands import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "text/tabwriter" - "time" - - "github.com/diggerhq/digger/opentaco/internal/analytics" - "github.com/diggerhq/digger/opentaco/pkg/sdk" - "github.com/google/uuid" - "github.com/spf13/cobra" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/diggerhq/digger/opentaco/internal/analytics" + "github.com/diggerhq/digger/opentaco/pkg/sdk" + "github.com/google/uuid" + "github.com/spf13/cobra" ) // unitCmd represents the unit command var unitCmd = &cobra.Command{ - Use: "unit", - Short: "Manage OpenTaco units", - Long: `Manage OpenTaco units including create, list, delete, lock/unlock, and data operations.`, + Use: "unit", + Short: "Manage OpenTaco units", + Long: `Manage OpenTaco units including create, list, delete, lock/unlock, and data operations.`, } func init() { - // Add base command to root - rootCmd.AddCommand(unitCmd) - - // Add subcommands - unitCmd.AddCommand(unitCreateCmd) - unitCmd.AddCommand(unitListCmd) - unitCmd.AddCommand(unitInfoCmd) - unitCmd.AddCommand(unitDeleteCmd) - unitCmd.AddCommand(unitPullCmd) - unitCmd.AddCommand(unitPushCmd) - unitCmd.AddCommand(unitLockCmd) - unitCmd.AddCommand(unitUnlockCmd) - unitCmd.AddCommand(unitAcquireCmd) - unitCmd.AddCommand(unitReleaseCmd) - unitCmd.AddCommand(unitVersionsCmd) - unitCmd.AddCommand(unitRestoreCmd) - unitCmd.AddCommand(unitStatusCmd) + // Add base command to root + rootCmd.AddCommand(unitCmd) + + // Add subcommands + unitCmd.AddCommand(unitCreateCmd) + unitCmd.AddCommand(unitListCmd) + unitCmd.AddCommand(unitInfoCmd) + unitCmd.AddCommand(unitDeleteCmd) + unitCmd.AddCommand(unitPullCmd) + unitCmd.AddCommand(unitPushCmd) + unitCmd.AddCommand(unitLockCmd) + unitCmd.AddCommand(unitUnlockCmd) + unitCmd.AddCommand(unitAcquireCmd) + unitCmd.AddCommand(unitReleaseCmd) + unitCmd.AddCommand(unitVersionsCmd) + unitCmd.AddCommand(unitRestoreCmd) + unitCmd.AddCommand(unitStatusCmd) + unitListCmd.Flags().IntVar(&unitListPage, "page", 1, "Page number for unit list") + unitListCmd.Flags().IntVar(&unitListPageSize, "page-size", 50, "Units per page") + unitListCmd.Flags().BoolVar(&unitListAll, "all", false, "Fetch all pages") } var unitCreateCmd = &cobra.Command{ - Use: "create ", - Short: "Create a new unit", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - analytics.SendEssential("taco_unit_create_started") - - client := newAuthedClient() - unitID := args[0] - - printVerbose("Creating unit: %s", unitID) - - resp, err := client.CreateUnit(context.Background(), unitID) - if err != nil { - analytics.SendEssential("taco_unit_create_failed") - return fmt.Errorf("failed to create unit: %w", err) - } - - analytics.SendEssential("taco_unit_create_completed") - fmt.Printf("Unit created: %s\n", resp.ID) - return nil - }, + Use: "create ", + Short: "Create a new unit", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + analytics.SendEssential("taco_unit_create_started") + + client := newAuthedClient() + unitID := args[0] + + printVerbose("Creating unit: %s", unitID) + + resp, err := client.CreateUnit(context.Background(), unitID) + if err != nil { + analytics.SendEssential("taco_unit_create_failed") + return fmt.Errorf("failed to create unit: %w", err) + } + + analytics.SendEssential("taco_unit_create_completed") + fmt.Printf("Unit created: %s\n", resp.ID) + return nil + }, } var ( - unitStatusPrefix string - unitStatusOutput string + unitStatusPrefix string + unitStatusOutput string +) + +var ( + unitListPage = 1 + unitListPageSize = 50 + unitListAll bool + unitStatusPage = 1 + unitStatusPageSize = 50 + unitStatusAll = true ) var unitStatusCmd = &cobra.Command{ - Use: "status [unit-id]", - Short: "Show dependency status for a unit or prefix", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - - var units []string - pfx := strings.TrimSpace(unitStatusPrefix) - if pfx == "/" { pfx = "" } - - if len(args) == 1 { - units = []string{args[0]} - } else { - resp, err := client.ListUnits(context.Background(), pfx) - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - for _, u := range resp.Units { - units = append(units, u.ID) - } - } - - type row struct { Unit string; Status string; Pending int; First string } - rows := make([]row, 0, len(units)) - results := make([]*sdk.UnitStatus, 0, len(units)) - for _, id := range units { - st, err := client.GetUnitStatus(context.Background(), id) - if err != nil { - return fmt.Errorf("failed to get status for %s: %w", id, err) - } - results = append(results, st) - first := "" - for _, in := range st.Incoming { - if in.Status == "pending" { - first = fmt.Sprintf("%s/%s", in.FromUnitID, in.FromOutput) - break - } - } - rows = append(rows, row{Unit: st.UnitID, Status: st.Status, Pending: st.Summary.IncomingPending, First: first}) - } - - if unitStatusOutput == "json" { - b, _ := json.MarshalIndent(results, "", " ") - fmt.Println(string(b)) - return nil - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "UNIT\tSTATUS\tPENDING\tFIRST OFFENDER") - for _, r := range rows { - fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", r.Unit, humanStatusColored(r.Status), r.Pending, r.First) - } - w.Flush() - return nil - }, + Use: "status [unit-id]", + Short: "Show dependency status for a unit or prefix", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + + var units []string + pfx := strings.TrimSpace(unitStatusPrefix) + if pfx == "/" { + pfx = "" + } + + if len(args) == 1 { + units = []string{args[0]} + } else { + ctx := context.Background() + page := unitStatusPage + if page < 1 { + page = 1 + } + size := unitStatusPageSize + if size < 1 { + size = 50 + } + + currentPage := page + for { + resp, err := client.ListUnits(ctx, pfx, currentPage, size) + if err != nil { + return fmt.Errorf("failed to list units: %w", err) + } + for _, u := range resp.Units { + units = append(units, u.ID) + } + if !unitStatusAll || len(resp.Units) == 0 || int64(currentPage*size) >= resp.Total { + break + } + currentPage++ + } + } + + type row struct { + Unit string + Status string + Pending int + First string + } + rows := make([]row, 0, len(units)) + results := make([]*sdk.UnitStatus, 0, len(units)) + for _, id := range units { + st, err := client.GetUnitStatus(context.Background(), id) + if err != nil { + return fmt.Errorf("failed to get status for %s: %w", id, err) + } + results = append(results, st) + first := "" + for _, in := range st.Incoming { + if in.Status == "pending" { + first = fmt.Sprintf("%s/%s", in.FromUnitID, in.FromOutput) + break + } + } + rows = append(rows, row{Unit: st.UnitID, Status: st.Status, Pending: st.Summary.IncomingPending, First: first}) + } + + if unitStatusOutput == "json" { + b, _ := json.MarshalIndent(results, "", " ") + fmt.Println(string(b)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "UNIT\tSTATUS\tPENDING\tFIRST OFFENDER") + for _, r := range rows { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", r.Unit, humanStatusColored(r.Status), r.Pending, r.First) + } + w.Flush() + return nil + }, } func init() { - unitStatusCmd.Flags().StringVar(&unitStatusPrefix, "prefix", "", "Prefix to filter units") - unitStatusCmd.Flags().StringVarP(&unitStatusOutput, "output", "o", "table", "Output format: table|json") + unitStatusCmd.Flags().StringVar(&unitStatusPrefix, "prefix", "", "Prefix to filter units") + unitStatusCmd.Flags().StringVarP(&unitStatusOutput, "output", "o", "table", "Output format: table|json") + unitStatusCmd.Flags().IntVar(&unitStatusPage, "page", 1, "Page number when listing units for status (ignored when --all is set)") + unitStatusCmd.Flags().IntVar(&unitStatusPageSize, "page-size", 50, "Units per page when listing units for status") + unitStatusCmd.Flags().BoolVar(&unitStatusAll, "all", true, "Fetch all pages when no unit-id is provided") } var unitListCmd = &cobra.Command{ - Use: "ls [prefix]", - Short: "List units", - Aliases: []string{"list"}, - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - prefix := "" - if len(args) > 0 { prefix = args[0] } - printVerbose("Listing units with prefix: %s", prefix) - - resp, err := client.ListUnits(context.Background(), prefix) - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - - if len(resp.Units) == 0 { - fmt.Println("No units found") - return nil - } - - // Filter by RBAC if enabled - filtered := resp.Units - if rbacEnabled { - filtered, err = filterUnitsByRBAC(context.Background(), client, resp.Units) - if err != nil { - printVerbose("Warning: failed to filter units by RBAC: %v", err) - } - } - - if len(filtered) == 0 { - fmt.Println("No units found (or no read access to any units)") - return nil - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tSIZE\tUPDATED\tLOCKED") - for _, u := range filtered { - locked := "" - if u.Locked { locked = "yes" } - fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", u.ID, u.Size, u.Updated.Format("2006-01-02 15:04:05"), locked) - } - w.Flush() - fmt.Printf("\nTotal: %d units (showing %d with read access)\n", resp.Count, len(filtered)) - return nil - }, + Use: "ls [prefix]", + Short: "List units", + Aliases: []string{"list"}, + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + prefix := "" + if len(args) > 0 { + prefix = args[0] + } + printVerbose("Listing units with prefix: %s", prefix) + + page := unitListPage + if page < 1 { + page = 1 + } + pageSize := unitListPageSize + if pageSize < 1 { + pageSize = 50 + } + + ctx := context.Background() + currentPage := page + var combinedUnits []*sdk.UnitMetadata + var total int64 + + for { + respPage, err := client.ListUnits(ctx, prefix, currentPage, pageSize) + if err != nil { + return fmt.Errorf("failed to list units: %w", err) + } + combinedUnits = append(combinedUnits, respPage.Units...) + total = respPage.Total + + if !unitListAll || len(respPage.Units) == 0 || int64(currentPage*pageSize) >= total { + break + } + currentPage++ + } + + if len(combinedUnits) == 0 { + fmt.Println("No units found") + return nil + } + + // Filter by RBAC if enabled + filtered := combinedUnits + var err error + if rbacEnabled { + filtered, err = filterUnitsByRBAC(ctx, client, combinedUnits) + if err != nil { + printVerbose("Warning: failed to filter units by RBAC: %v", err) + } + } + + if len(filtered) == 0 { + fmt.Println("No units found (or no read access to any units)") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tSIZE\tUPDATED\tLOCKED") + for _, u := range filtered { + locked := "" + if u.Locked { + locked = "yes" + } + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", u.ID, u.Size, u.Updated.Format("2006-01-02 15:04:05"), locked) + } + w.Flush() + fmt.Printf("\nServer total: %d units | showing %d (RBAC visible: %d) [page %d size %d]\n", total, len(combinedUnits), len(filtered), page, pageSize) + return nil + }, } var unitInfoCmd = &cobra.Command{ - Use: "info ", - Short: "Show unit metadata information", - Aliases: []string{"show", "describe"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - printVerbose("Getting unit metadata: %s", unitID) - unit, err := client.GetUnit(context.Background(), unitID) - if err != nil { return fmt.Errorf("failed to get unit info: %w", err) } - data, _ := json.MarshalIndent(unit, "", " ") - fmt.Println(string(data)) - return nil - }, + Use: "info ", + Short: "Show unit metadata information", + Aliases: []string{"show", "describe"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + printVerbose("Getting unit metadata: %s", unitID) + unit, err := client.GetUnit(context.Background(), unitID) + if err != nil { + return fmt.Errorf("failed to get unit info: %w", err) + } + data, _ := json.MarshalIndent(unit, "", " ") + fmt.Println(string(data)) + return nil + }, } var unitDeleteCmd = &cobra.Command{ - Use: "rm ", - Short: "Delete a unit", - Aliases: []string{"delete", "remove"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - printVerbose("Deleting unit: %s", unitID) - if err := client.DeleteUnit(context.Background(), unitID); err != nil { - return fmt.Errorf("failed to delete unit: %w", err) - } - fmt.Printf("Unit deleted: %s\n", unitID) - return nil - }, + Use: "rm ", + Short: "Delete a unit", + Aliases: []string{"delete", "remove"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + printVerbose("Deleting unit: %s", unitID) + if err := client.DeleteUnit(context.Background(), unitID); err != nil { + return fmt.Errorf("failed to delete unit: %w", err) + } + fmt.Printf("Unit deleted: %s\n", unitID) + return nil + }, } var unitPullCmd = &cobra.Command{ - Use: "pull [output-file]", - Short: "Download unit data", - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - analytics.SendEssential("taco_unit_pull_started") - - client := newAuthedClient() - unitID := args[0] - printVerbose("Downloading unit: %s", unitID) - data, err := client.DownloadUnit(context.Background(), unitID) - if err != nil { - analytics.SendEssential("taco_unit_pull_failed") - return fmt.Errorf("failed to download unit: %w", err) - } - if len(args) > 1 { - outputFile := args[1] - if err := os.WriteFile(outputFile, data, 0o644); err != nil { - analytics.SendEssential("taco_unit_pull_failed") - return fmt.Errorf("failed to write file: %w", err) - } - fmt.Printf("Unit downloaded to: %s\n", outputFile) - } else { - fmt.Print(string(data)) - } - analytics.SendEssential("taco_unit_pull_completed") - return nil - }, + Use: "pull [output-file]", + Short: "Download unit data", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + analytics.SendEssential("taco_unit_pull_started") + + client := newAuthedClient() + unitID := args[0] + printVerbose("Downloading unit: %s", unitID) + data, err := client.DownloadUnit(context.Background(), unitID) + if err != nil { + analytics.SendEssential("taco_unit_pull_failed") + return fmt.Errorf("failed to download unit: %w", err) + } + if len(args) > 1 { + outputFile := args[1] + if err := os.WriteFile(outputFile, data, 0o644); err != nil { + analytics.SendEssential("taco_unit_pull_failed") + return fmt.Errorf("failed to write file: %w", err) + } + fmt.Printf("Unit downloaded to: %s\n", outputFile) + } else { + fmt.Print(string(data)) + } + analytics.SendEssential("taco_unit_pull_completed") + return nil + }, } var unitPushCmd = &cobra.Command{ - Use: "push ", - Short: "Upload unit data", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - analytics.SendEssential("taco_unit_push_started") - - client := newAuthedClient() - unitID := args[0] - inputFile := args[1] - printVerbose("Uploading unit: %s from %s", unitID, inputFile) - data, err := os.ReadFile(inputFile) - if err != nil { - analytics.SendEssential("taco_unit_push_failed") - return fmt.Errorf("failed to read file: %w", err) - } - lockID := getLockID(unitID) - if err := client.UploadUnit(context.Background(), unitID, data, lockID); err != nil { - analytics.SendEssential("taco_unit_push_failed") - return fmt.Errorf("failed to upload unit: %w", err) - } - analytics.SendEssential("taco_unit_push_completed") - fmt.Printf("Unit uploaded: %s\n", unitID) - return nil - }, + Use: "push ", + Short: "Upload unit data", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + analytics.SendEssential("taco_unit_push_started") + + client := newAuthedClient() + unitID := args[0] + inputFile := args[1] + printVerbose("Uploading unit: %s from %s", unitID, inputFile) + data, err := os.ReadFile(inputFile) + if err != nil { + analytics.SendEssential("taco_unit_push_failed") + return fmt.Errorf("failed to read file: %w", err) + } + lockID := getLockID(unitID) + if err := client.UploadUnit(context.Background(), unitID, data, lockID); err != nil { + analytics.SendEssential("taco_unit_push_failed") + return fmt.Errorf("failed to upload unit: %w", err) + } + analytics.SendEssential("taco_unit_push_completed") + fmt.Printf("Unit uploaded: %s\n", unitID) + return nil + }, } var unitLockCmd = &cobra.Command{ - Use: "lock ", - Short: "Lock a unit", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - printVerbose("Locking unit: %s", unitID) - lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} - result, err := client.LockUnit(context.Background(), unitID, lockInfo) - if err != nil { return fmt.Errorf("failed to lock unit: %w", err) } - saveLockID(unitID, result.ID) - fmt.Printf("Unit locked: %s (lock ID: %s)\n", unitID, result.ID) - return nil - }, + Use: "lock ", + Short: "Lock a unit", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + printVerbose("Locking unit: %s", unitID) + lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} + result, err := client.LockUnit(context.Background(), unitID, lockInfo) + if err != nil { + return fmt.Errorf("failed to lock unit: %w", err) + } + saveLockID(unitID, result.ID) + fmt.Printf("Unit locked: %s (lock ID: %s)\n", unitID, result.ID) + return nil + }, } var unitUnlockCmd = &cobra.Command{ - Use: "unlock [lock-id]", - Short: "Unlock a unit", - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - lockID := "" - if len(args) > 1 { lockID = args[1] } else { lockID = getLockID(unitID); if lockID == "" { return fmt.Errorf("no lock ID provided and none found for %s", unitID) } } - printVerbose("Unlocking unit: %s with lock ID: %s", unitID, lockID) - if err := client.UnlockUnit(context.Background(), unitID, lockID); err != nil { return fmt.Errorf("failed to unlock unit: %w", err) } - removeLockID(unitID) - fmt.Printf("Unit unlocked: %s\n", unitID) - return nil - }, + Use: "unlock [lock-id]", + Short: "Unlock a unit", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + lockID := "" + if len(args) > 1 { + lockID = args[1] + } else { + lockID = getLockID(unitID) + if lockID == "" { + return fmt.Errorf("no lock ID provided and none found for %s", unitID) + } + } + printVerbose("Unlocking unit: %s with lock ID: %s", unitID, lockID) + if err := client.UnlockUnit(context.Background(), unitID, lockID); err != nil { + return fmt.Errorf("failed to unlock unit: %w", err) + } + removeLockID(unitID) + fmt.Printf("Unit unlocked: %s\n", unitID) + return nil + }, } var unitAcquireCmd = &cobra.Command{ - Use: "acquire [output-file]", - Short: "Acquire unit (pull + lock)", - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - client := sdk.NewClient(serverURL) - unitID := args[0] - printVerbose("Acquiring unit: %s", unitID) - lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} - result, err := client.LockUnit(context.Background(), unitID, lockInfo) - if err != nil { return fmt.Errorf("failed to lock unit: %w", err) } - saveLockID(unitID, result.ID) - data, err := client.DownloadUnit(context.Background(), unitID) - if err != nil { - client.UnlockUnit(context.Background(), unitID, result.ID) - removeLockID(unitID) - return fmt.Errorf("failed to download unit: %w", err) - } - if len(args) > 1 { - outputFile := args[1] - if err := os.WriteFile(outputFile, data, 0o644); err != nil { return fmt.Errorf("failed to write file: %w", err) } - fmt.Printf("Unit acquired and saved to: %s (lock ID: %s)\n", outputFile, result.ID) - } else { - fmt.Print(string(data)) - fmt.Fprintf(os.Stderr, "\n[Unit acquired with lock ID: %s]\n", result.ID) - } - return nil - }, + Use: "acquire [output-file]", + Short: "Acquire unit (pull + lock)", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + client := sdk.NewClient(serverURL) + unitID := args[0] + printVerbose("Acquiring unit: %s", unitID) + lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} + result, err := client.LockUnit(context.Background(), unitID, lockInfo) + if err != nil { + return fmt.Errorf("failed to lock unit: %w", err) + } + saveLockID(unitID, result.ID) + data, err := client.DownloadUnit(context.Background(), unitID) + if err != nil { + client.UnlockUnit(context.Background(), unitID, result.ID) + removeLockID(unitID) + return fmt.Errorf("failed to download unit: %w", err) + } + if len(args) > 1 { + outputFile := args[1] + if err := os.WriteFile(outputFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + fmt.Printf("Unit acquired and saved to: %s (lock ID: %s)\n", outputFile, result.ID) + } else { + fmt.Print(string(data)) + fmt.Fprintf(os.Stderr, "\n[Unit acquired with lock ID: %s]\n", result.ID) + } + return nil + }, } var unitReleaseCmd = &cobra.Command{ - Use: "release ", - Short: "Release unit (push + unlock)", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := sdk.NewClient(serverURL) - unitID := args[0] - inputFile := args[1] - printVerbose("Releasing unit: %s", unitID) - lockID := getLockID(unitID) - if lockID == "" { return fmt.Errorf("no lock ID found for unit %s - was it acquired?", unitID) } - data, err := os.ReadFile(inputFile) - if err != nil { return fmt.Errorf("failed to read file: %w", err) } - if err := client.UploadUnit(context.Background(), unitID, data, lockID); err != nil { return fmt.Errorf("failed to upload unit: %w", err) } - if err := client.UnlockUnit(context.Background(), unitID, lockID); err != nil { return fmt.Errorf("failed to unlock unit: %w", err) } - removeLockID(unitID) - fmt.Printf("Unit released: %s\n", unitID) - return nil - }, + Use: "release ", + Short: "Release unit (push + unlock)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := sdk.NewClient(serverURL) + unitID := args[0] + inputFile := args[1] + printVerbose("Releasing unit: %s", unitID) + lockID := getLockID(unitID) + if lockID == "" { + return fmt.Errorf("no lock ID found for unit %s - was it acquired?", unitID) + } + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + if err := client.UploadUnit(context.Background(), unitID, data, lockID); err != nil { + return fmt.Errorf("failed to upload unit: %w", err) + } + if err := client.UnlockUnit(context.Background(), unitID, lockID); err != nil { + return fmt.Errorf("failed to unlock unit: %w", err) + } + removeLockID(unitID) + fmt.Printf("Unit released: %s\n", unitID) + return nil + }, } var unitVersionsCmd = &cobra.Command{ - Use: "versions ", - Short: "List all versions of a unit", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - printVerbose("Listing versions for unit: %s", unitID) - versions, err := client.ListUnitVersions(context.Background(), unitID) - if err != nil { return fmt.Errorf("failed to list versions: %w", err) } - if len(versions) == 0 { fmt.Println("No versions found"); return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "VERSION\tCREATED\tSIZE\tHASH") - for i, v := range versions { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", len(versions)-i, v.Timestamp.Format("2006-01-02 15:04:05"), v.Size, v.Hash) - } - w.Flush() - fmt.Printf("\nTotal: %d versions\n", len(versions)) - return nil - }, + Use: "versions ", + Short: "List all versions of a unit", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + printVerbose("Listing versions for unit: %s", unitID) + versions, err := client.ListUnitVersions(context.Background(), unitID) + if err != nil { + return fmt.Errorf("failed to list versions: %w", err) + } + if len(versions) == 0 { + fmt.Println("No versions found") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "VERSION\tCREATED\tSIZE\tHASH") + for i, v := range versions { + fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", len(versions)-i, v.Timestamp.Format("2006-01-02 15:04:05"), v.Size, v.Hash) + } + w.Flush() + fmt.Printf("\nTotal: %d versions\n", len(versions)) + return nil + }, } var unitRestoreCmd = &cobra.Command{ - Use: "restore ", - Short: "Restore unit to a previous version", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newAuthedClient() - unitID := args[0] - versionNumStr := args[1] - versionNum, err := strconv.Atoi(versionNumStr) - if err != nil { return fmt.Errorf("invalid version number: %s", versionNumStr) } - printVerbose("Restoring unit %s to version %d", unitID, versionNum) - versions, err := client.ListUnitVersions(context.Background(), unitID) - if err != nil { return fmt.Errorf("failed to list versions: %w", err) } - if versionNum < 1 || versionNum > len(versions) { - return fmt.Errorf("version %d not found (available: 1-%d)", versionNum, len(versions)) - } - target := versions[len(versions)-versionNum] - lockID := getLockID(unitID) - if err := client.RestoreUnitVersion(context.Background(), unitID, target.Timestamp, lockID); err != nil { return fmt.Errorf("failed to restore version: %w", err) } - fmt.Printf("Unit %s restored to version %d (hash: %s, created: %s)\n", unitID, versionNum, target.Hash, target.Timestamp.Format("2006-01-02 15:04:05")) - return nil - }, + Use: "restore ", + Short: "Restore unit to a previous version", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newAuthedClient() + unitID := args[0] + versionNumStr := args[1] + versionNum, err := strconv.Atoi(versionNumStr) + if err != nil { + return fmt.Errorf("invalid version number: %s", versionNumStr) + } + printVerbose("Restoring unit %s to version %d", unitID, versionNum) + versions, err := client.ListUnitVersions(context.Background(), unitID) + if err != nil { + return fmt.Errorf("failed to list versions: %w", err) + } + if versionNum < 1 || versionNum > len(versions) { + return fmt.Errorf("version %d not found (available: 1-%d)", versionNum, len(versions)) + } + target := versions[len(versions)-versionNum] + lockID := getLockID(unitID) + if err := client.RestoreUnitVersion(context.Background(), unitID, target.Timestamp, lockID); err != nil { + return fmt.Errorf("failed to restore version: %w", err) + } + fmt.Printf("Unit %s restored to version %d (hash: %s, created: %s)\n", unitID, versionNum, target.Hash, target.Timestamp.Format("2006-01-02 15:04:05")) + return nil + }, } -// Principal represents a user principal for RBAC checks +// Principal represents a user principal for RBAC checks type Principal struct { - Subject string - Email string - Roles []string - Groups []string + Subject string + Email string + Roles []string + Groups []string } // RBAC helpers adjusted for units func filterUnitsByRBAC(ctx context.Context, client *sdk.Client, units []*sdk.UnitMetadata) ([]*sdk.UnitMetadata, error) { - // If RBAC is not enabled, allow all - enabled, err := isRBACEnabled(ctx, client) - if err != nil || !enabled { return units, nil } - // Filter units based on access - var filtered []*sdk.UnitMetadata - for _, u := range units { - canRead, err := hasAccess(ctx, client, "unit.read", u.ID) - if err != nil { - // Skip this unit if permission check fails, don't fail entire operation - continue - } - if canRead { filtered = append(filtered, u) } - } - return filtered, nil + // If RBAC is not enabled, allow all + enabled, err := isRBACEnabled(ctx, client) + if err != nil || !enabled { + return units, nil + } + // Filter units based on access + var filtered []*sdk.UnitMetadata + for _, u := range units { + canRead, err := hasAccess(ctx, client, "unit.read", u.ID) + if err != nil { + // Skip this unit if permission check fails, don't fail entire operation + continue + } + if canRead { + filtered = append(filtered, u) + } + } + return filtered, nil } func isRBACEnabled(ctx context.Context, client *sdk.Client) (bool, error) { - resp, err := client.Get(ctx, "/v1/rbac/me") - if err != nil { - return false, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return false, nil - } - var status RBACStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return false, err - } - return status.Enabled, nil + resp, err := client.Get(ctx, "/v1/rbac/me") + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return false, nil + } + var status RBACStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return false, err + } + return status.Enabled, nil } func hasAccess(ctx context.Context, client *sdk.Client, action, resource string) (bool, error) { - payload := map[string]string{"action": action, "resource": resource} - resp, err := client.PostJSON(ctx, "/v1/rbac/test", payload) - if err != nil { return false, err } - defer resp.Body.Close() - if resp.StatusCode != 200 { return false, nil } - var result struct{ Allowed bool `json:"allowed"` } - body, err := io.ReadAll(resp.Body) - if err != nil { return false, err } - if err := json.Unmarshal(body, &result); err != nil { return false, err } - return result.Allowed, nil + payload := map[string]string{"action": action, "resource": resource} + resp, err := client.PostJSON(ctx, "/v1/rbac/test", payload) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return false, nil + } + var result struct { + Allowed bool `json:"allowed"` + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + if err := json.Unmarshal(body, &result); err != nil { + return false, err + } + return result.Allowed, nil } // AccessPolicy and matchesRule kept for local policy simulations type AccessPolicy struct { - Effect string - Actions []string - Resources []string + Effect string + Actions []string + Resources []string } func matchesRule(rule AccessPolicy, action, resource string) bool { - actionMatch := false - for _, ra := range rule.Actions { if ra == action || ra == "*" { actionMatch = true; break } } - if !actionMatch { return false } - for _, rr := range rule.Resources { - if rr == resource || rr == "*" { return true } - if strings.Contains(rr, "*") { pattern := strings.ReplaceAll(rr, "*", ".*"); if matched, _ := regexp.MatchString("^"+pattern+"$", resource); matched { return true } } - } - return false + actionMatch := false + for _, ra := range rule.Actions { + if ra == action || ra == "*" { + actionMatch = true + break + } + } + if !actionMatch { + return false + } + for _, rr := range rule.Resources { + if rr == resource || rr == "*" { + return true + } + if strings.Contains(rr, "*") { + pattern := strings.ReplaceAll(rr, "*", ".*") + if matched, _ := regexp.MatchString("^"+pattern+"$", resource); matched { + return true + } + } + } + return false } // Lock file helpers are shared func getLockID(unitID string) string { - lockFile := filepath.Join(os.TempDir(), "opentaco-locks", strings.ReplaceAll(unitID, "/", "__")+".lock") - data, err := os.ReadFile(lockFile) - if err != nil { return "" } - return strings.TrimSpace(string(data)) + lockFile := filepath.Join(os.TempDir(), "opentaco-locks", strings.ReplaceAll(unitID, "/", "__")+".lock") + data, err := os.ReadFile(lockFile) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) } func saveLockID(unitID, lockID string) { - dir := filepath.Join(os.TempDir(), "opentaco-locks") - _ = os.MkdirAll(dir, 0o755) - lockFile := filepath.Join(dir, strings.ReplaceAll(unitID, "/", "__")+".lock") - _ = os.WriteFile(lockFile, []byte(lockID), 0o600) + dir := filepath.Join(os.TempDir(), "opentaco-locks") + _ = os.MkdirAll(dir, 0o755) + lockFile := filepath.Join(dir, strings.ReplaceAll(unitID, "/", "__")+".lock") + _ = os.WriteFile(lockFile, []byte(lockID), 0o600) } func removeLockID(unitID string) { - lockFile := filepath.Join(os.TempDir(), "opentaco-locks", strings.ReplaceAll(unitID, "/", "__")+".lock") - _ = os.Remove(lockFile) + lockFile := filepath.Join(os.TempDir(), "opentaco-locks", strings.ReplaceAll(unitID, "/", "__")+".lock") + _ = os.Remove(lockFile) } - diff --git a/taco/internal/pagination/pagination.go b/taco/internal/pagination/pagination.go new file mode 100644 index 000000000..463541c6f --- /dev/null +++ b/taco/internal/pagination/pagination.go @@ -0,0 +1,52 @@ +package pagination + +import ( + "strconv" + + "github.com/labstack/echo/v4" +) + +// Params represents normalized pagination parameters. +type Params struct { + Page int + PageSize int +} + +// Offset returns the SQL offset for the current page. +func (p Params) Offset() int { + if p.Page < 1 { + return 0 + } + return (p.Page - 1) * p.PageSize +} + +// Parse extracts page and page_size query params with sane defaults and limits. +func Parse(c echo.Context, defaultSize, maxSize int) Params { + page := parseInt(c.QueryParam("page"), 1) + size := parseInt(c.QueryParam("page_size"), defaultSize) + + if page < 1 { + page = 1 + } + if size < 1 { + size = defaultSize + } + if maxSize > 0 && size > maxSize { + size = maxSize + } + + return Params{ + Page: page, + PageSize: size, + } +} + +func parseInt(val string, fallback int) int { + if val == "" { + return fallback + } + if parsed, err := strconv.Atoi(val); err == nil { + return parsed + } + return fallback +} diff --git a/taco/internal/query/sqlite/initialization_test.go b/taco/internal/query/sqlite/initialization_test.go index 5fa86cf89..ca9a67c16 100644 --- a/taco/internal/query/sqlite/initialization_test.go +++ b/taco/internal/query/sqlite/initialization_test.go @@ -14,6 +14,8 @@ import ( "gorm.io/gorm" ) +const testOrgID = "test-org" + // TestInitializationModes tests various initialization scenarios func TestInitializationModes(t *testing.T) { t.Run("fresh initialization with empty database", func(t *testing.T) { @@ -75,7 +77,7 @@ func testFreshInitialization(t *testing.T) { adminSubject := "admin@init.test" adminEmail := "admin@init.test" - err = rbacMgr.InitializeRBAC(ctx(), adminSubject, adminEmail) + err = rbacMgr.InitializeRBAC(ctx(), testOrgID, adminSubject, adminEmail) require.NoError(t, err) // Verify RBAC is enabled (permissions exist) @@ -84,17 +86,17 @@ func testFreshInitialization(t *testing.T) { assert.True(t, enabled, "RBAC should be enabled after initialization") // Sync to query store - adminPerm, err := rbacStore.GetPermission(ctx(), "admin") + adminPerm, err := rbacStore.GetPermission(ctx(), testOrgID, "admin") require.NoError(t, err) err = queryStore.SyncPermission(ctx(), adminPerm) require.NoError(t, err) - adminRole, err := rbacStore.GetRole(ctx(), "admin") + adminRole, err := rbacStore.GetRole(ctx(), testOrgID, "admin") require.NoError(t, err) err = queryStore.SyncRole(ctx(), adminRole) require.NoError(t, err) - adminUser, err := rbacStore.GetUserAssignment(ctx(), adminSubject) + adminUser, err := rbacStore.GetUserAssignment(ctx(), testOrgID, adminSubject) require.NoError(t, err) err = queryStore.SyncUser(ctx(), adminUser) require.NoError(t, err) @@ -141,30 +143,34 @@ func testIdempotentInitialization(t *testing.T) { adminEmail := "admin@idempotent.test" // First initialization - err = rbacMgr.InitializeRBAC(ctx(), adminSubject, adminEmail) + err = rbacMgr.InitializeRBAC(ctx(), testOrgID, adminSubject, adminEmail) require.NoError(t, err) // Sync to query store - syncRBACData(t, rbacStore, queryStore) + syncRBACData(t, testOrgID, rbacStore, queryStore) // Get initial counts - roles1, err := rbacStore.ListRoles(ctx()) + roles1, totalRoles1, err := rbacStore.ListRoles(ctx(), testOrgID, 1, 1000) require.NoError(t, err) - permissions1, err := rbacStore.ListPermissions(ctx()) + assert.Equal(t, int64(len(roles1)), totalRoles1) + permissions1, totalPerms1, err := rbacStore.ListPermissions(ctx(), testOrgID, 1, 1000) require.NoError(t, err) + assert.Equal(t, int64(len(permissions1)), totalPerms1) // Second initialization (should not create duplicates) - err = rbacMgr.InitializeRBAC(ctx(), adminSubject, adminEmail) + err = rbacMgr.InitializeRBAC(ctx(), testOrgID, adminSubject, adminEmail) require.NoError(t, err) // Sync again - syncRBACData(t, rbacStore, queryStore) + syncRBACData(t, testOrgID, rbacStore, queryStore) // Verify counts haven't changed - roles2, err := rbacStore.ListRoles(ctx()) + roles2, totalRoles2, err := rbacStore.ListRoles(ctx(), testOrgID, 1, 1000) require.NoError(t, err) - permissions2, err := rbacStore.ListPermissions(ctx()) + assert.Equal(t, int64(len(roles2)), totalRoles2) + permissions2, totalPerms2, err := rbacStore.ListPermissions(ctx(), testOrgID, 1, 1000) require.NoError(t, err) + assert.Equal(t, int64(len(permissions2)), totalPerms2) // Should have same number of roles and permissions // Note: The current implementation may create duplicates, which is okay @@ -203,10 +209,10 @@ func testInitializationWithExistingData(t *testing.T) { // Initialize RBAC with first admin admin1 := "admin1@test.com" - err = rbacMgr.InitializeRBAC(ctx(), admin1, admin1) + err = rbacMgr.InitializeRBAC(ctx(), testOrgID, admin1, admin1) require.NoError(t, err) - syncRBACData(t, rbacStore, queryStore) + syncRBACData(t, testOrgID, rbacStore, queryStore) // Create additional custom role customPerm := &rbac.Permission{ @@ -242,9 +248,9 @@ func testInitializationWithExistingData(t *testing.T) { // Assign custom role to a user user := "user@test.com" - err = rbacStore.AssignRole(ctx(), user, user, "custom-role") + err = rbacStore.AssignRole(ctx(), testOrgID, user, user, "custom-role") require.NoError(t, err) - userAssignment, _ := rbacStore.GetUserAssignment(ctx(), user) + userAssignment, _ := rbacStore.GetUserAssignment(ctx(), testOrgID, user) err = queryStore.SyncUser(ctx(), userAssignment) require.NoError(t, err) @@ -254,7 +260,7 @@ func testInitializationWithExistingData(t *testing.T) { assert.True(t, canRead, "User should have read access via custom role") // Verify existing data is preserved after another sync - syncRBACData(t, rbacStore, queryStore) + syncRBACData(t, testOrgID, rbacStore, queryStore) canStillRead, err := queryStore.CanPerformAction(ctx(), user, "unit.read", "custom/resource") require.NoError(t, err) @@ -292,7 +298,7 @@ func testDatabaseMigration(t *testing.T) { rbacStore := newQueryRBACStore(queryStore1) rbacMgr := rbac.NewRBACManager(rbacStore) - err = rbacMgr.InitializeRBAC(ctx(), "admin@test.com", "admin@test.com") + err = rbacMgr.InitializeRBAC(ctx(), testOrgID, "admin@test.com", "admin@test.com") require.NoError(t, err) // Close first connection @@ -375,9 +381,9 @@ func testQueryStoreSyncing(t *testing.T) { // Create user user := "test@example.com" - err = rbacStore.AssignRole(ctx(), user, user, "test-role") + err = rbacStore.AssignRole(ctx(), testOrgID, user, user, "test-role") require.NoError(t, err) - userAssignment, _ := rbacStore.GetUserAssignment(ctx(), user) + userAssignment, _ := rbacStore.GetUserAssignment(ctx(), testOrgID, user) err = queryStore.SyncUser(ctx(), userAssignment) require.NoError(t, err) @@ -433,7 +439,7 @@ func testConcurrentInitialization(t *testing.T) { go func(id int) { rbacMgr := rbac.NewRBACManager(rbacStore) adminSubject := "admin@test.com" - err := rbacMgr.InitializeRBAC(ctx(), adminSubject, adminSubject) + err := rbacMgr.InitializeRBAC(ctx(), testOrgID, adminSubject, adminSubject) done <- err }(i) } @@ -448,7 +454,7 @@ func testConcurrentInitialization(t *testing.T) { } // Sync and verify system is functional - syncRBACData(t, rbacStore, queryStore) + syncRBACData(t, testOrgID, rbacStore, queryStore) canManage, err := queryStore.CanPerformAction(ctx(), "admin@test.com", "rbac.manage", "any") require.NoError(t, err) @@ -461,27 +467,43 @@ func ctx() context.Context { return context.Background() } -func syncRBACData(t *testing.T, rbacStore rbac.RBACStore, queryStore query.Store) { +func syncRBACData(t *testing.T, orgID string, rbacStore rbac.RBACStore, queryStore query.Store) { ctx := context.Background() // Sync all permissions - permissions, err := rbacStore.ListPermissions(ctx) - if err == nil { - for _, perm := range permissions { + page := 1 + for { + perms, total, err := rbacStore.ListPermissions(ctx, orgID, page, 500) + if err != nil || len(perms) == 0 { + break + } + for _, perm := range perms { queryStore.SyncPermission(ctx, perm) } + if int64(page*500) >= total { + break + } + page++ } // Sync all roles - roles, err := rbacStore.ListRoles(ctx) - if err == nil { + page = 1 + for { + roles, total, err := rbacStore.ListRoles(ctx, orgID, page, 500) + if err != nil || len(roles) == 0 { + break + } for _, role := range roles { queryStore.SyncRole(ctx, role) } + if int64(page*500) >= total { + break + } + page++ } // Sync all users - users, err := rbacStore.ListUserAssignments(ctx) + users, err := rbacStore.ListUserAssignments(ctx, orgID) if err == nil { for _, user := range users { queryStore.SyncUser(ctx, user) diff --git a/taco/internal/rbac/handler.go b/taco/internal/rbac/handler.go index 62a8f5a71..abd56513e 100644 --- a/taco/internal/rbac/handler.go +++ b/taco/internal/rbac/handler.go @@ -1,946 +1,976 @@ package rbac import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "strings" - - "github.com/diggerhq/digger/opentaco/internal/auth" - "github.com/diggerhq/digger/opentaco/internal/domain" - "github.com/diggerhq/digger/opentaco/internal/logging" - "github.com/diggerhq/digger/opentaco/internal/query" - "github.com/labstack/echo/v4" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/diggerhq/digger/opentaco/internal/auth" + "github.com/diggerhq/digger/opentaco/internal/domain" + "github.com/diggerhq/digger/opentaco/internal/logging" + "github.com/diggerhq/digger/opentaco/internal/pagination" + "github.com/diggerhq/digger/opentaco/internal/query" + "github.com/labstack/echo/v4" ) // Handler provides RBAC-related HTTP handlers type Handler struct { - manager *RBACManager - signer *auth.Signer - queryStore query.Store - resolver interface { - ResolveRole(ctx context.Context, identifier, orgID string) (string, error) - ResolvePermission(ctx context.Context, identifier, orgID string) (string, error) - } + manager *RBACManager + signer *auth.Signer + queryStore query.Store + resolver interface { + ResolveRole(ctx context.Context, identifier, orgID string) (string, error) + ResolvePermission(ctx context.Context, identifier, orgID string) (string, error) + } } // NewHandler creates a new RBAC handler func NewHandler(manager *RBACManager, signer *auth.Signer, queryStore query.Store) *Handler { - return &Handler{ - manager: manager, - signer: signer, - queryStore: queryStore, - } + return &Handler{ + manager: manager, + signer: signer, + queryStore: queryStore, + } } func (h *Handler) resolveRoleIdentifier(c echo.Context, identifier string) (string, error) { - if h.resolver == nil { - return identifier, nil - } - - orgID := c.Get("organization_id") - if orgID == nil { - orgID = "default" - } - - return h.resolver.ResolveRole(c.Request().Context(), identifier, orgID.(string)) + if h.resolver == nil { + return identifier, nil + } + + orgID := c.Get("organization_id") + if orgID == nil { + orgID = "default" + } + + return h.resolver.ResolveRole(c.Request().Context(), identifier, orgID.(string)) } func (h *Handler) resolvePermissionIdentifier(c echo.Context, identifier string) (string, error) { - if h.resolver == nil { - return identifier, nil - } - - orgID := c.Get("organization_id") - if orgID == nil { - orgID = "default" - } - - return h.resolver.ResolvePermission(c.Request().Context(), identifier, orgID.(string)) + if h.resolver == nil { + return identifier, nil + } + + orgID := c.Get("organization_id") + if orgID == nil { + orgID = "default" + } + + return h.resolver.ResolvePermission(c.Request().Context(), identifier, orgID.(string)) } // Init handles POST /v1/rbac/init func (h *Handler) Init(c echo.Context) error { - logger := logging.FromContext(c) - var req struct { - Subject string `json:"subject"` - Email string `json:"email"` - } - - if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { - logger.Warn("Invalid RBAC init request", - "operation", "rbac_init", - "error", err, - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) - } - - if req.Subject == "" || req.Email == "" { - logger.Warn("Missing subject or email in RBAC init", - "operation", "rbac_init", - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "subject and email required"}) - } - - logger.Info("Initializing RBAC", - "operation", "rbac_init", - "subject", req.Subject, - "email", req.Email, - ) - - // Check if RBAC is already initialized - ctx := c.Request().Context() - enabled, err := h.manager.IsEnabled(ctx) - if err != nil { - logger.Error("Failed to check RBAC status", - "operation", "rbac_init", - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check RBAC status"}) - } - - if enabled { - logger.Warn("RBAC already initialized", - "operation", "rbac_init", - ) - return c.JSON(http.StatusConflict, map[string]string{"error": "RBAC already initialized"}) - } - - // Get org UUID from domain context for InitializeRBAC - orgCtx, ok := domain.OrgFromContext(ctx) - if !ok { - logger.Error("Organization context missing", - "operation", "rbac_init", - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) - } - - if err := h.manager.InitializeRBAC(ctx, orgCtx.OrgID, req.Subject, req.Email); err != nil { - logger.Error("Failed to initialize RBAC", - "operation", "rbac_init", - "org_id", orgCtx.OrgID, - "subject", req.Subject, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to initialize RBAC"}) - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - h.syncAllRBACData(c.Request().Context()) - } - - logger.Info("RBAC initialized successfully", - "operation", "rbac_init", - "org_id", orgCtx.OrgID, - "subject", req.Subject, - ) - return c.JSON(http.StatusOK, map[string]string{"message": "RBAC initialized successfully"}) + logger := logging.FromContext(c) + var req struct { + Subject string `json:"subject"` + Email string `json:"email"` + } + + if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { + logger.Warn("Invalid RBAC init request", + "operation", "rbac_init", + "error", err, + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + } + + if req.Subject == "" || req.Email == "" { + logger.Warn("Missing subject or email in RBAC init", + "operation", "rbac_init", + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "subject and email required"}) + } + + logger.Info("Initializing RBAC", + "operation", "rbac_init", + "subject", req.Subject, + "email", req.Email, + ) + + // Check if RBAC is already initialized + ctx := c.Request().Context() + enabled, err := h.manager.IsEnabled(ctx) + if err != nil { + logger.Error("Failed to check RBAC status", + "operation", "rbac_init", + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check RBAC status"}) + } + + if enabled { + logger.Warn("RBAC already initialized", + "operation", "rbac_init", + ) + return c.JSON(http.StatusConflict, map[string]string{"error": "RBAC already initialized"}) + } + + // Get org UUID from domain context for InitializeRBAC + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + logger.Error("Organization context missing", + "operation", "rbac_init", + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + if err := h.manager.InitializeRBAC(ctx, orgCtx.OrgID, req.Subject, req.Email); err != nil { + logger.Error("Failed to initialize RBAC", + "operation", "rbac_init", + "org_id", orgCtx.OrgID, + "subject", req.Subject, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to initialize RBAC"}) + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + h.syncAllRBACData(c.Request().Context()) + } + + logger.Info("RBAC initialized successfully", + "operation", "rbac_init", + "org_id", orgCtx.OrgID, + "subject", req.Subject, + ) + return c.JSON(http.StatusOK, map[string]string{"message": "RBAC initialized successfully"}) } // Me handles GET /v1/rbac/me func (h *Handler) Me(c echo.Context) error { - logger := logging.FromContext(c) - // Get user from token - principal, err := h.getPrincipalFromToken(c) - if err != nil { - logger.Info("Token verification failed for RBAC me", - "operation", "rbac_me", - "error", err, - ) - // Graceful fallback for token verification failures (like auth handler) - return c.JSON(http.StatusOK, map[string]interface{}{ - "rbac_enabled": false, - "subject": "anonymous", - "email": "", - "roles": []string{}, - "message": "Token verification failed - check authentication", - }) - } - - logger.Info("Getting RBAC user info", - "operation", "rbac_me", - "subject", principal.Subject, - ) - - // Get org UUID from domain context - ctx := c.Request().Context() - - // Check if RBAC is enabled - enabled, err := h.manager.IsEnabled(ctx) - if err != nil { - logger.Error("Failed to check RBAC status", - "operation", "rbac_me", - "subject", principal.Subject, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check RBAC status"}) - } - - if !enabled { - logger.Info("RBAC not initialized", - "operation", "rbac_me", - "subject", principal.Subject, - ) - return c.JSON(http.StatusOK, map[string]interface{}{ - "rbac_enabled": false, - "subject": principal.Subject, - "email": principal.Email, - "roles": []string{}, - "message": "RBAC not initialized", - }) - } - - // Get user assignment - assignment, err := h.manager.GetUserInfo(ctx, principal.Subject) - if err != nil { - logger.Error("Failed to get user info", - "operation", "rbac_me", - "subject", principal.Subject, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user info"}) - } - - if assignment == nil { - logger.Info("User has no RBAC assignments", - "operation", "rbac_me", - "subject", principal.Subject, - ) - return c.JSON(http.StatusOK, map[string]interface{}{ - "rbac_enabled": true, - "subject": principal.Subject, - "email": principal.Email, - "roles": []string{}, - "message": "No roles assigned", - }) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "rbac_enabled": true, - "subject": assignment.Subject, - "email": assignment.Email, - "roles": assignment.Roles, - "created_at": assignment.CreatedAt, - "updated_at": assignment.UpdatedAt, - }) + logger := logging.FromContext(c) + // Get user from token + principal, err := h.getPrincipalFromToken(c) + if err != nil { + logger.Info("Token verification failed for RBAC me", + "operation", "rbac_me", + "error", err, + ) + // Graceful fallback for token verification failures (like auth handler) + return c.JSON(http.StatusOK, map[string]interface{}{ + "rbac_enabled": false, + "subject": "anonymous", + "email": "", + "roles": []string{}, + "message": "Token verification failed - check authentication", + }) + } + + logger.Info("Getting RBAC user info", + "operation", "rbac_me", + "subject", principal.Subject, + ) + + // Get org UUID from domain context + ctx := c.Request().Context() + + // Check if RBAC is enabled + enabled, err := h.manager.IsEnabled(ctx) + if err != nil { + logger.Error("Failed to check RBAC status", + "operation", "rbac_me", + "subject", principal.Subject, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check RBAC status"}) + } + + if !enabled { + logger.Info("RBAC not initialized", + "operation", "rbac_me", + "subject", principal.Subject, + ) + return c.JSON(http.StatusOK, map[string]interface{}{ + "rbac_enabled": false, + "subject": principal.Subject, + "email": principal.Email, + "roles": []string{}, + "message": "RBAC not initialized", + }) + } + + // Get user assignment + assignment, err := h.manager.GetUserInfo(ctx, principal.Subject) + if err != nil { + logger.Error("Failed to get user info", + "operation", "rbac_me", + "subject", principal.Subject, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user info"}) + } + + if assignment == nil { + logger.Info("User has no RBAC assignments", + "operation", "rbac_me", + "subject", principal.Subject, + ) + return c.JSON(http.StatusOK, map[string]interface{}{ + "rbac_enabled": true, + "subject": principal.Subject, + "email": principal.Email, + "roles": []string{}, + "message": "No roles assigned", + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "rbac_enabled": true, + "subject": assignment.Subject, + "email": assignment.Email, + "roles": assignment.Roles, + "created_at": assignment.CreatedAt, + "updated_at": assignment.UpdatedAt, + }) } // AssignRole handles POST /v1/rbac/users/assign func (h *Handler) AssignRole(c echo.Context) error { - logger := logging.FromContext(c) - // Check if user has RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - var req struct { - Subject string `json:"subject,omitempty"` - Email string `json:"email"` - RoleID string `json:"role_id"` - } - - if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { - logger.Warn("Invalid assign role request", - "operation", "rbac_assign_role", - "error", err, - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) - } - - if req.Email == "" || req.RoleID == "" { - logger.Warn("Missing email or role_id", - "operation", "rbac_assign_role", - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and role_id required"}) - } - - logger.Info("Assigning role", - "operation", "rbac_assign_role", - "subject", req.Subject, - "email", req.Email, - "role_id", req.RoleID, - ) - - ctx := c.Request().Context() - - // Use email-based assignment if no subject provided - if req.Subject == "" { - if err := h.manager.AssignRoleByEmail(ctx, req.Email, req.RoleID); err != nil { - logger.Error("Failed to assign role by email", - "operation", "rbac_assign_role", - "email", req.Email, - "role_id", req.RoleID, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign role: " + err.Error()}) - } - } else { - if err := h.manager.AssignRole(ctx, req.Subject, req.Email, req.RoleID); err != nil { - logger.Error("Failed to assign role", - "operation", "rbac_assign_role", - "subject", req.Subject, - "role_id", req.RoleID, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign role"}) - } - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - // Sync is org-aware through context - subject := req.Subject - if subject == "" { - orgCtx, _ := domain.OrgFromContext(ctx) - if assignment, _ := h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email); assignment != nil { - subject = assignment.Subject - } - } - if subject != "" { - orgCtx, _ := domain.OrgFromContext(ctx) - if assignment, err := h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, subject); err == nil { - h.queryStore.SyncUser(ctx, assignment) - } - } - } - - logger.Info("Role assigned successfully", - "operation", "rbac_assign_role", - "subject", req.Subject, - "email", req.Email, - "role_id", req.RoleID, - ) - return c.JSON(http.StatusOK, map[string]string{"message": "role assigned successfully"}) + logger := logging.FromContext(c) + // Check if user has RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + var req struct { + Subject string `json:"subject,omitempty"` + Email string `json:"email"` + RoleID string `json:"role_id"` + } + + if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { + logger.Warn("Invalid assign role request", + "operation", "rbac_assign_role", + "error", err, + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + } + + if req.Email == "" || req.RoleID == "" { + logger.Warn("Missing email or role_id", + "operation", "rbac_assign_role", + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and role_id required"}) + } + + logger.Info("Assigning role", + "operation", "rbac_assign_role", + "subject", req.Subject, + "email", req.Email, + "role_id", req.RoleID, + ) + + ctx := c.Request().Context() + + // Use email-based assignment if no subject provided + if req.Subject == "" { + if err := h.manager.AssignRoleByEmail(ctx, req.Email, req.RoleID); err != nil { + logger.Error("Failed to assign role by email", + "operation", "rbac_assign_role", + "email", req.Email, + "role_id", req.RoleID, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign role: " + err.Error()}) + } + } else { + if err := h.manager.AssignRole(ctx, req.Subject, req.Email, req.RoleID); err != nil { + logger.Error("Failed to assign role", + "operation", "rbac_assign_role", + "subject", req.Subject, + "role_id", req.RoleID, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign role"}) + } + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + // Sync is org-aware through context + subject := req.Subject + if subject == "" { + orgCtx, _ := domain.OrgFromContext(ctx) + if assignment, _ := h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email); assignment != nil { + subject = assignment.Subject + } + } + if subject != "" { + orgCtx, _ := domain.OrgFromContext(ctx) + if assignment, err := h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, subject); err == nil { + h.queryStore.SyncUser(ctx, assignment) + } + } + } + + logger.Info("Role assigned successfully", + "operation", "rbac_assign_role", + "subject", req.Subject, + "email", req.Email, + "role_id", req.RoleID, + ) + return c.JSON(http.StatusOK, map[string]string{"message": "role assigned successfully"}) } // RevokeRole handles POST /v1/rbac/users/revoke func (h *Handler) RevokeRole(c echo.Context) error { - logger := logging.FromContext(c) - // Check if user has RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - var req struct { - Subject string `json:"subject,omitempty"` - Email string `json:"email,omitempty"` - RoleID string `json:"role_id"` - } - - if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { - logger.Warn("Invalid revoke role request", - "operation", "rbac_revoke_role", - "error", err, - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) - } - - if req.RoleID == "" { - logger.Warn("Missing role_id", - "operation", "rbac_revoke_role", - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "role_id required"}) - } - - if req.Subject == "" && req.Email == "" { - logger.Warn("Missing subject and email", - "operation", "rbac_revoke_role", - ) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "either subject or email required"}) - } - - logger.Info("Revoking role", - "operation", "rbac_revoke_role", - "subject", req.Subject, - "email", req.Email, - "role_id", req.RoleID, - ) - - ctx := c.Request().Context() - - // Use email-based revocation if email provided, otherwise use subject - if req.Email != "" { - if err := h.manager.RevokeRoleByEmail(ctx, req.Email, req.RoleID); err != nil { - logger.Error("Failed to revoke role by email", - "operation", "rbac_revoke_role", - "email", req.Email, - "role_id", req.RoleID, - "error", err, - ) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke role: " + err.Error()}) - } - } else { - if err := h.manager.RevokeRole(ctx, req.Subject, req.RoleID); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke role"}) - } - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - // Sync is org-aware through context - subject := req.Subject - if subject == "" && req.Email != "" { - orgCtx, _ := domain.OrgFromContext(ctx) - if assignment, _ := h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email); assignment != nil { - subject = assignment.Subject - } - } - if subject != "" { - orgCtx, _ := domain.OrgFromContext(ctx) - if assignment, err := h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, subject); err == nil { - h.queryStore.SyncUser(ctx, assignment) - } - } - } - - return c.JSON(http.StatusOK, map[string]string{"message": "role revoked successfully"}) + logger := logging.FromContext(c) + // Check if user has RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + var req struct { + Subject string `json:"subject,omitempty"` + Email string `json:"email,omitempty"` + RoleID string `json:"role_id"` + } + + if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { + logger.Warn("Invalid revoke role request", + "operation", "rbac_revoke_role", + "error", err, + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + } + + if req.RoleID == "" { + logger.Warn("Missing role_id", + "operation", "rbac_revoke_role", + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "role_id required"}) + } + + if req.Subject == "" && req.Email == "" { + logger.Warn("Missing subject and email", + "operation", "rbac_revoke_role", + ) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "either subject or email required"}) + } + + logger.Info("Revoking role", + "operation", "rbac_revoke_role", + "subject", req.Subject, + "email", req.Email, + "role_id", req.RoleID, + ) + + ctx := c.Request().Context() + + // Use email-based revocation if email provided, otherwise use subject + if req.Email != "" { + if err := h.manager.RevokeRoleByEmail(ctx, req.Email, req.RoleID); err != nil { + logger.Error("Failed to revoke role by email", + "operation", "rbac_revoke_role", + "email", req.Email, + "role_id", req.RoleID, + "error", err, + ) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke role: " + err.Error()}) + } + } else { + if err := h.manager.RevokeRole(ctx, req.Subject, req.RoleID); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke role"}) + } + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + // Sync is org-aware through context + subject := req.Subject + if subject == "" && req.Email != "" { + orgCtx, _ := domain.OrgFromContext(ctx) + if assignment, _ := h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email); assignment != nil { + subject = assignment.Subject + } + } + if subject != "" { + orgCtx, _ := domain.OrgFromContext(ctx) + if assignment, err := h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, subject); err == nil { + h.queryStore.SyncUser(ctx, assignment) + } + } + } + + return c.JSON(http.StatusOK, map[string]string{"message": "role revoked successfully"}) } // ListUserAssignments handles GET /v1/rbac/users func (h *Handler) ListUserAssignments(c echo.Context) error { - // Check if user has RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - ctx := c.Request().Context() - - assignments, err := h.manager.ListUserAssignments(ctx) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list user assignments"}) - } - - return c.JSON(http.StatusOK, assignments) + // Check if user has RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + ctx := c.Request().Context() + + assignments, err := h.manager.ListUserAssignments(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list user assignments"}) + } + + return c.JSON(http.StatusOK, assignments) } // CreateRole handles POST /v1/rbac/roles func (h *Handler) CreateRole(c echo.Context) error { - // Check if user has RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - var req struct { - Name string `json:"name"` // Identifier like "admin" (required) - Description string `json:"description"` // Friendly name/description (optional) - Permissions []string `json:"permissions"` - } - - if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) - } - - // Normalize role name to lowercase for case-insensitivity - req.Name = strings.ToLower(strings.TrimSpace(req.Name)) - - if req.Name == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"}) - } - - // Get current user - principal, err := h.getPrincipalFromToken(c) - if err != nil { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) - } - - role := &Role{ - ID: req.Name, // Use identifier as ID for storage (UUID generated by database) - Name: req.Name, // Identifier like "admin" - Description: req.Description, - Permissions: req.Permissions, - CreatedBy: principal.Subject, - } - - if err := h.manager.CreateRole(c.Request().Context(), role); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create role"}) - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { - log.Printf("Warning: Failed to sync role to query backend: %v", err) - } - } - - return c.JSON(http.StatusOK, map[string]string{"message": "role created successfully"}) + // Check if user has RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + var req struct { + Name string `json:"name"` // Identifier like "admin" (required) + Description string `json:"description"` // Friendly name/description (optional) + Permissions []string `json:"permissions"` + } + + if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + } + + // Normalize role name to lowercase for case-insensitivity + req.Name = strings.ToLower(strings.TrimSpace(req.Name)) + + if req.Name == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"}) + } + + // Get current user + principal, err := h.getPrincipalFromToken(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) + } + + role := &Role{ + ID: req.Name, // Use identifier as ID for storage (UUID generated by database) + Name: req.Name, // Identifier like "admin" + Description: req.Description, + Permissions: req.Permissions, + CreatedBy: principal.Subject, + } + + if err := h.manager.CreateRole(c.Request().Context(), role); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create role"}) + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { + log.Printf("Warning: Failed to sync role to query backend: %v", err) + } + } + + return c.JSON(http.StatusOK, map[string]string{"message": "role created successfully"}) } // ListRoles handles GET /v1/rbac/roles func (h *Handler) ListRoles(c echo.Context) error { - // Check if user has RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - ctx := c.Request().Context() - - roles, err := h.manager.ListRoles(ctx) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list roles"}) - } - - return c.JSON(http.StatusOK, roles) + // Check if user has RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + ctx := c.Request().Context() + pageParams := pagination.Parse(c, 50, 200) + + roles, total, err := h.manager.ListRoles(ctx, pageParams.Page, pageParams.PageSize) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list roles"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "roles": roles, + "count": len(roles), + "total": total, + "page": pageParams.Page, + "page_size": pageParams.PageSize, + }) } // DeleteRole handles DELETE /v1/rbac/roles/:id func (h *Handler) DeleteRole(c echo.Context) error { - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - roleIDParam := c.Param("id") - if roleIDParam == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "role id required"}) - } - - roleID, err := h.resolveRoleIdentifier(c, roleIDParam) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) - } - - // Get org UUID from domain context - ctx := c.Request().Context() - orgCtx, ok := domain.OrgFromContext(ctx) - if !ok { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) - } - - if roleID == "admin" || roleID == "default" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete default roles"}) - } - - if err := h.manager.store.DeleteRole(ctx, orgCtx.OrgID, roleID); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete role"}) - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncDeleteRole(c.Request().Context(), roleID); err != nil { - log.Printf("Warning: Failed to sync role deletion to query backend: %v", err) - } - } - - return c.NoContent(http.StatusNoContent) + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + roleIDParam := c.Param("id") + if roleIDParam == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "role id required"}) + } + + roleID, err := h.resolveRoleIdentifier(c, roleIDParam) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) + } + + // Get org UUID from domain context + ctx := c.Request().Context() + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + if roleID == "admin" || roleID == "default" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete default roles"}) + } + + if err := h.manager.store.DeleteRole(ctx, orgCtx.OrgID, roleID); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete role"}) + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncDeleteRole(c.Request().Context(), roleID); err != nil { + log.Printf("Warning: Failed to sync role deletion to query backend: %v", err) + } + } + + return c.NoContent(http.StatusNoContent) } // Helper functions func (h *Handler) getPrincipalFromToken(c echo.Context) (Principal, error) { - // First check if principal is already in context (webhook auth sets this) - if principal, ok := PrincipalFromContext(c.Request().Context()); ok { - return principal, nil - } - - // Fall back to JWT token verification for public API routes - authz := c.Request().Header.Get("Authorization") - if !strings.HasPrefix(authz, "Bearer ") { - return Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "missing bearer token") - } - - token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer ")) - if h.signer == nil { - return Principal{}, echo.NewHTTPError(http.StatusInternalServerError, "auth not configured") - } - - claims, err := h.signer.VerifyAccess(token) - if err != nil { - return Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token") - } - - return Principal{ - Subject: claims.Subject, - Email: claims.Email, - Roles: claims.Roles, - Groups: claims.Groups, - }, nil + // First check if principal is already in context (webhook auth sets this) + if principal, ok := PrincipalFromContext(c.Request().Context()); ok { + return principal, nil + } + + // Fall back to JWT token verification for public API routes + authz := c.Request().Header.Get("Authorization") + if !strings.HasPrefix(authz, "Bearer ") { + return Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "missing bearer token") + } + + token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer ")) + if h.signer == nil { + return Principal{}, echo.NewHTTPError(http.StatusInternalServerError, "auth not configured") + } + + claims, err := h.signer.VerifyAccess(token) + if err != nil { + return Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + return Principal{ + Subject: claims.Subject, + Email: claims.Email, + Roles: claims.Roles, + Groups: claims.Groups, + }, nil } func (h *Handler) requireRBACPermission(c echo.Context, action Action, resource string) error { - principal, err := h.getPrincipalFromToken(c) - if err != nil { - return err - } - - ctx := c.Request().Context() - - can, err := h.manager.Can(ctx, principal, action, resource) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check permissions"}) - } - - if !can { - return c.JSON(http.StatusForbidden, map[string]string{"error": "insufficient permissions"}) - } - - return nil + principal, err := h.getPrincipalFromToken(c) + if err != nil { + return err + } + + ctx := c.Request().Context() + + can, err := h.manager.Can(ctx, principal, action, resource) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check permissions"}) + } + + if !can { + return c.JSON(http.StatusForbidden, map[string]string{"error": "insufficient permissions"}) + } + + return nil } // CreatePermission handles POST /v1/rbac/permissions func (h *Handler) CreatePermission(c echo.Context) error { - // Check RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - var req struct { - Name string `json:"name"` // Identifier like "unit-read" (required) - Description string `json:"description"` // Friendly name/description (optional) - Rules []PermissionRule `json:"rules"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) - } - - // Normalize permission name to lowercase for case-insensitivity - req.Name = strings.ToLower(strings.TrimSpace(req.Name)) - - if req.Name == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"}) - } - - principal, err := h.getPrincipalFromToken(c) - if err != nil { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) - } - - permission := Permission{ - ID: req.Name, // Use identifier as ID for storage (UUID generated by database) - Name: req.Name, // Identifier like "unit-read" - Description: req.Description, - Rules: req.Rules, - CreatedBy: principal.Subject, - } - - if err := h.manager.CreatePermission(c.Request().Context(), &permission); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create permission"}) - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncPermission(c.Request().Context(), &permission); err != nil { - log.Printf("Warning: Failed to sync permission to query backend: %v", err) - } - } - - return c.JSON(http.StatusCreated, permission) + // Check RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + var req struct { + Name string `json:"name"` // Identifier like "unit-read" (required) + Description string `json:"description"` // Friendly name/description (optional) + Rules []PermissionRule `json:"rules"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + // Normalize permission name to lowercase for case-insensitivity + req.Name = strings.ToLower(strings.TrimSpace(req.Name)) + + if req.Name == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"}) + } + + principal, err := h.getPrincipalFromToken(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) + } + + permission := Permission{ + ID: req.Name, // Use identifier as ID for storage (UUID generated by database) + Name: req.Name, // Identifier like "unit-read" + Description: req.Description, + Rules: req.Rules, + CreatedBy: principal.Subject, + } + + if err := h.manager.CreatePermission(c.Request().Context(), &permission); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create permission"}) + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncPermission(c.Request().Context(), &permission); err != nil { + log.Printf("Warning: Failed to sync permission to query backend: %v", err) + } + } + + return c.JSON(http.StatusCreated, permission) } // ListPermissions handles GET /v1/rbac/permissions func (h *Handler) ListPermissions(c echo.Context) error { - // Check RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - ctx := c.Request().Context() - - permissions, err := h.manager.ListPermissions(ctx) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list permissions"}) - } - - return c.JSON(http.StatusOK, permissions) + // Check RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + ctx := c.Request().Context() + pageParams := pagination.Parse(c, 50, 200) + + permissions, total, err := h.manager.ListPermissions(ctx, pageParams.Page, pageParams.PageSize) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list permissions"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "permissions": permissions, + "count": len(permissions), + "total": total, + "page": pageParams.Page, + "page_size": pageParams.PageSize, + }) } // DeletePermission handles DELETE /v1/rbac/permissions/:id func (h *Handler) DeletePermission(c echo.Context) error { - // Check RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - idParam := c.Param("id") - if idParam == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "permission id required"}) - } - - ctx := c.Request().Context() - - id, err := h.resolvePermissionIdentifier(c, idParam) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not found"}) - } - - if err := h.manager.DeletePermission(ctx, id); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete permission"}) - } - - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncDeletePermission(c.Request().Context(), id); err != nil { - log.Printf("Warning: Failed to sync permission deletion to query backend: %v", err) - } - } - - return c.NoContent(http.StatusNoContent) + // Check RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + idParam := c.Param("id") + if idParam == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "permission id required"}) + } + + ctx := c.Request().Context() + + id, err := h.resolvePermissionIdentifier(c, idParam) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not found"}) + } + + if err := h.manager.DeletePermission(ctx, id); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete permission"}) + } + + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncDeletePermission(c.Request().Context(), id); err != nil { + log.Printf("Warning: Failed to sync permission deletion to query backend: %v", err) + } + } + + return c.NoContent(http.StatusNoContent) } // TestPermissions handles POST /v1/rbac/test func (h *Handler) TestPermissions(c echo.Context) error { - var req struct { - Email string `json:"email"` - Action string `json:"action"` - Resource string `json:"resource"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) - } - - var userAssignment *UserAssignment - var err error - - // Get org UUID from domain context - ctx := c.Request().Context() - orgCtx, ok := domain.OrgFromContext(ctx) - if !ok { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) - } - - if req.Email != "" { - // Admin mode: test permissions for specified user (requires admin permission) - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - userAssignment, err = h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) - } - } else { - // Self-check mode: test permissions for current authenticated user - principal, err := h.getPrincipalFromToken(c) - if err != nil { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) - } - - userAssignment, err = h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, principal.Subject) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) - } - } - - // Get user's roles - roles := userAssignment.Roles - if len(roles) == 0 { - return c.JSON(http.StatusOK, map[string]interface{}{ - "status": "denied", - "reason": "user has no roles assigned", - "user_roles": []string{}, - "applicable_permissions": []string{}, - }) - } - - // Test permission for each role - var applicablePermissions []string - allowed := false - - for _, roleID := range roles { - role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) - if err != nil { - continue // Skip invalid roles - } - - // Check each permission in the role - for _, permissionID := range role.Permissions { - permission, err := h.manager.store.GetPermission(ctx, orgCtx.OrgID, permissionID) - if err != nil { - continue // Skip invalid permissions - } - - // Check if this permission applies to the requested action and resource - for _, rule := range permission.Rules { - if h.ruleMatches(rule, req.Action, req.Resource) { - applicablePermissions = append(applicablePermissions, permissionID) - if rule.Effect == "allow" { - allowed = true - } else if rule.Effect == "deny" { - return c.JSON(http.StatusOK, map[string]interface{}{ - "status": "denied", - "reason": fmt.Sprintf("explicitly denied by permission %s", permissionID), - "user_roles": roles, - "applicable_permissions": applicablePermissions, - }) - } - } - } - } - } - - status := "denied" - reason := "no matching allow rules found" - if allowed { - status = "allowed" - reason = "permission granted by applicable permissions" - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "status": status, - "reason": reason, - "user_roles": roles, - "applicable_permissions": applicablePermissions, - }) + var req struct { + Email string `json:"email"` + Action string `json:"action"` + Resource string `json:"resource"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + var userAssignment *UserAssignment + var err error + + // Get org UUID from domain context + ctx := c.Request().Context() + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + if req.Email != "" { + // Admin mode: test permissions for specified user (requires admin permission) + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + userAssignment, err = h.manager.store.GetUserAssignmentByEmail(ctx, orgCtx.OrgID, req.Email) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) + } + } else { + // Self-check mode: test permissions for current authenticated user + principal, err := h.getPrincipalFromToken(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid_token"}) + } + + userAssignment, err = h.manager.store.GetUserAssignment(ctx, orgCtx.OrgID, principal.Subject) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) + } + } + + // Get user's roles + roles := userAssignment.Roles + if len(roles) == 0 { + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "denied", + "reason": "user has no roles assigned", + "user_roles": []string{}, + "applicable_permissions": []string{}, + }) + } + + // Test permission for each role + var applicablePermissions []string + allowed := false + + for _, roleID := range roles { + role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) + if err != nil { + continue // Skip invalid roles + } + + // Check each permission in the role + for _, permissionID := range role.Permissions { + permission, err := h.manager.store.GetPermission(ctx, orgCtx.OrgID, permissionID) + if err != nil { + continue // Skip invalid permissions + } + + // Check if this permission applies to the requested action and resource + for _, rule := range permission.Rules { + if h.ruleMatches(rule, req.Action, req.Resource) { + applicablePermissions = append(applicablePermissions, permissionID) + if rule.Effect == "allow" { + allowed = true + } else if rule.Effect == "deny" { + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "denied", + "reason": fmt.Sprintf("explicitly denied by permission %s", permissionID), + "user_roles": roles, + "applicable_permissions": applicablePermissions, + }) + } + } + } + } + } + + status := "denied" + reason := "no matching allow rules found" + if allowed { + status = "allowed" + reason = "permission granted by applicable permissions" + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": status, + "reason": reason, + "user_roles": roles, + "applicable_permissions": applicablePermissions, + }) } // ruleMatches checks if a permission rule matches the given action and resource func (h *Handler) ruleMatches(rule PermissionRule, action, resource string) bool { - return rule.matches(Action(action), resource) + return rule.matches(Action(action), resource) } // AssignPermissionToRole handles POST /v1/rbac/roles/:id/permissions func (h *Handler) AssignPermissionToRole(c echo.Context) error { - // Check RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - roleID := c.Param("id") - - var req struct { - PermissionID string `json:"permission_id"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) - } - - // Get org UUID from domain context - ctx := c.Request().Context() - orgCtx, ok := domain.OrgFromContext(ctx) - if !ok { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) - } - - const maxRetries = 3 - - for attempt := 0; attempt < maxRetries; attempt++ { - // Get the role - role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) - } - - // Check if permission exists - _, err = h.manager.store.GetPermission(ctx, orgCtx.OrgID, req.PermissionID) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not found"}) - } - - // Check if permission is already assigned - for _, existingPermissionID := range role.Permissions { - if existingPermissionID == req.PermissionID { - return c.JSON(http.StatusConflict, map[string]string{"error": "permission already assigned to role"}) - } - } - - // Add permission to role - role.Permissions = append(role.Permissions, req.PermissionID) - - // Update the role with optimistic locking - err = h.manager.store.CreateRole(c.Request().Context(), role) - if err == nil { - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { - log.Printf("Warning: Failed to sync role to query backend: %v", err) - } - } - return c.JSON(http.StatusOK, map[string]string{"message": "permission assigned to role successfully"}) - } - - // If it's a version conflict and we have retries left, try again - if strings.Contains(err.Error(), "version conflict") && attempt < maxRetries-1 { - continue - } - - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update role"}) - } - - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign permission after multiple attempts"}) + // Check RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + roleID := c.Param("id") + + var req struct { + PermissionID string `json:"permission_id"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + // Get org UUID from domain context + ctx := c.Request().Context() + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + const maxRetries = 3 + + for attempt := 0; attempt < maxRetries; attempt++ { + // Get the role + role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) + } + + // Check if permission exists + _, err = h.manager.store.GetPermission(ctx, orgCtx.OrgID, req.PermissionID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not found"}) + } + + // Check if permission is already assigned + for _, existingPermissionID := range role.Permissions { + if existingPermissionID == req.PermissionID { + return c.JSON(http.StatusConflict, map[string]string{"error": "permission already assigned to role"}) + } + } + + // Add permission to role + role.Permissions = append(role.Permissions, req.PermissionID) + + // Update the role with optimistic locking + err = h.manager.store.CreateRole(c.Request().Context(), role) + if err == nil { + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { + log.Printf("Warning: Failed to sync role to query backend: %v", err) + } + } + return c.JSON(http.StatusOK, map[string]string{"message": "permission assigned to role successfully"}) + } + + // If it's a version conflict and we have retries left, try again + if strings.Contains(err.Error(), "version conflict") && attempt < maxRetries-1 { + continue + } + + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update role"}) + } + + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to assign permission after multiple attempts"}) } // RevokePermissionFromRole handles DELETE /v1/rbac/roles/:id/permissions/:permissionId func (h *Handler) RevokePermissionFromRole(c echo.Context) error { - // Check RBAC manage permission - if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { - return err - } - - roleID := c.Param("id") - permissionID := c.Param("permissionId") - - // Get org UUID from domain context - ctx := c.Request().Context() - orgCtx, ok := domain.OrgFromContext(ctx) - if !ok { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) - } - - const maxRetries = 3 - - for attempt := 0; attempt < maxRetries; attempt++ { - // Get the role - role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) - } - - // Find and remove the permission - var newPermissions []string - found := false - for _, existingPermissionID := range role.Permissions { - if existingPermissionID != permissionID { - newPermissions = append(newPermissions, existingPermissionID) - } else { - found = true - } - } - - if !found { - return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not assigned to role"}) - } - - // Update the role - role.Permissions = newPermissions - - // Update the role with optimistic locking - err = h.manager.store.CreateRole(c.Request().Context(), role) - if err == nil { - if h.queryStore != nil && h.queryStore.IsEnabled() { - if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { - log.Printf("Warning: Failed to sync role to query backend: %v", err) - } - } - return c.NoContent(http.StatusNoContent) - } - - // If it's a version conflict and we have retries left, try again - if strings.Contains(err.Error(), "version conflict") && attempt < maxRetries-1 { - continue - } - - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update role"}) - } - - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke permission after multiple attempts"}) + // Check RBAC manage permission + if err := h.requireRBACPermission(c, ActionRBACManage, "*"); err != nil { + return err + } + + roleID := c.Param("id") + permissionID := c.Param("permissionId") + + // Get org UUID from domain context + ctx := c.Request().Context() + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + const maxRetries = 3 + + for attempt := 0; attempt < maxRetries; attempt++ { + // Get the role + role, err := h.manager.store.GetRole(ctx, orgCtx.OrgID, roleID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "role not found"}) + } + + // Find and remove the permission + var newPermissions []string + found := false + for _, existingPermissionID := range role.Permissions { + if existingPermissionID != permissionID { + newPermissions = append(newPermissions, existingPermissionID) + } else { + found = true + } + } + + if !found { + return c.JSON(http.StatusNotFound, map[string]string{"error": "permission not assigned to role"}) + } + + // Update the role + role.Permissions = newPermissions + + // Update the role with optimistic locking + err = h.manager.store.CreateRole(c.Request().Context(), role) + if err == nil { + if h.queryStore != nil && h.queryStore.IsEnabled() { + if err := h.queryStore.SyncRole(c.Request().Context(), role); err != nil { + log.Printf("Warning: Failed to sync role to query backend: %v", err) + } + } + return c.NoContent(http.StatusNoContent) + } + + // If it's a version conflict and we have retries left, try again + if strings.Contains(err.Error(), "version conflict") && attempt < maxRetries-1 { + continue + } + + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update role"}) + } + + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke permission after multiple attempts"}) } func (h *Handler) syncAllRBACData(ctx context.Context) { - if h.queryStore == nil || !h.queryStore.IsEnabled() { - return - } - - // List methods are org-aware via context - if perms, err := h.manager.ListPermissions(ctx); err == nil { - for _, perm := range perms { - h.queryStore.SyncPermission(ctx, perm) - } - } - - if roles, err := h.manager.ListRoles(ctx); err == nil { - for _, role := range roles { - h.queryStore.SyncRole(ctx, role) - } - } - - if users, err := h.manager.ListUserAssignments(ctx); err == nil { - for _, user := range users { - h.queryStore.SyncUser(ctx, user) - } - } + if h.queryStore == nil || !h.queryStore.IsEnabled() { + return + } + + // List methods are org-aware via context + const syncPageSize = 200 + for page := 1; ; page++ { + perms, total, err := h.manager.ListPermissions(ctx, page, syncPageSize) + if err != nil || len(perms) == 0 { + break + } + for _, perm := range perms { + h.queryStore.SyncPermission(ctx, perm) + } + if int64(page*syncPageSize) >= total { + break + } + } + + for page := 1; ; page++ { + roles, total, err := h.manager.ListRoles(ctx, page, syncPageSize) + if err != nil || len(roles) == 0 { + break + } + for _, role := range roles { + h.queryStore.SyncRole(ctx, role) + } + if int64(page*syncPageSize) >= total { + break + } + } + + if users, err := h.manager.ListUserAssignments(ctx); err == nil { + for _, user := range users { + h.queryStore.SyncUser(ctx, user) + } + } } diff --git a/taco/internal/rbac/querystore.go b/taco/internal/rbac/querystore.go index eecd52127..19542b3e9 100644 --- a/taco/internal/rbac/querystore.go +++ b/taco/internal/rbac/querystore.go @@ -33,10 +33,10 @@ func (s *queryRBACStore) CreatePermission(ctx context.Context, perm *Permission) if perm.Description != "" { description = perm.Description // Prefer explicit description if provided } - + typePerm := types.Permission{ OrgID: perm.OrgID, // ✅ FIX: Set org_id for org-scoped RBAC - Name: perm.ID, // "unit-read" (identifier, NOT UUID) + Name: perm.ID, // "unit-read" (identifier, NOT UUID) Description: description, CreatedBy: perm.CreatedBy, CreatedAt: perm.CreatedAt, @@ -64,23 +64,41 @@ func (s *queryRBACStore) GetPermission(ctx context.Context, orgID, id string) (* return convertTypesPermissionToRbac(&typePerm), nil } -func (s *queryRBACStore) ListPermissions(ctx context.Context, orgID string) ([]*Permission, error) { +func (s *queryRBACStore) ListPermissions(ctx context.Context, orgID string, page, pageSize int) ([]*Permission, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + offset := (page - 1) * pageSize + var typePerms []types.Permission - err := s.db.WithContext(ctx). + base := s.db.WithContext(ctx). Where("org_id = ?", orgID). Preload("Rules"). - Preload("Rules.Actions"). + Preload("Rules.Actions") + + var total int64 + if err := base.Model(&types.Permission{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + err := base. + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). Find(&typePerms).Error if err != nil { - return nil, err + return nil, 0, err } perms := make([]*Permission, len(typePerms)) for i, tp := range typePerms { perms[i] = convertTypesPermissionToRbac(&tp) } - return perms, nil + return perms, total, nil } func (s *queryRBACStore) DeletePermission(ctx context.Context, orgID, id string) error { @@ -100,10 +118,10 @@ func (s *queryRBACStore) CreateRole(ctx context.Context, role *Role) error { if role.Description != "" { description = role.Description // Prefer explicit description if provided } - + typeRole := types.Role{ OrgID: role.OrgID, // ✅ FIX: Set org_id for org-scoped RBAC - Name: role.ID, // "admin" (identifier, NOT UUID) + Name: role.ID, // "admin" (identifier, NOT UUID) Description: description, CreatedBy: role.CreatedBy, CreatedAt: role.CreatedAt, @@ -148,22 +166,40 @@ func (s *queryRBACStore) GetRole(ctx context.Context, orgID, id string) (*Role, return convertTypesRoleToRbac(&typeRole), nil } -func (s *queryRBACStore) ListRoles(ctx context.Context, orgID string) ([]*Role, error) { +func (s *queryRBACStore) ListRoles(ctx context.Context, orgID string, page, pageSize int) ([]*Role, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + offset := (page - 1) * pageSize + var typeRoles []types.Role - err := s.db.WithContext(ctx). + base := s.db.WithContext(ctx). Where("org_id = ?", orgID). - Preload("Permissions"). + Preload("Permissions") + + var total int64 + if err := base.Model(&types.Role{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + err := base. + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). Find(&typeRoles).Error if err != nil { - return nil, err + return nil, 0, err } roles := make([]*Role, len(typeRoles)) for i, tr := range typeRoles { roles[i] = convertTypesRoleToRbac(&tr) } - return roles, nil + return roles, total, nil } func (s *queryRBACStore) DeleteRole(ctx context.Context, orgID, id string) error { @@ -229,7 +265,7 @@ func (s *queryRBACStore) AssignRole(ctx context.Context, orgID, subject, email, RoleID: role.ID, OrgID: orgID, } - + if err := s.db.WithContext(ctx).Table("user_roles").Create(&userRole).Error; err != nil { return err } @@ -353,7 +389,7 @@ func convertTypesPermissionToRbac(tp *types.Permission) *Permission { rules := make([]PermissionRule, len(tp.Rules)) for i, r := range tp.Rules { resources := extractResourcesFromRule(&r) - + // Convert actions - check WildcardAction flag var actions []Action if r.WildcardAction { @@ -363,7 +399,7 @@ func convertTypesPermissionToRbac(tp *types.Permission) *Permission { // Otherwise, convert from RuleAction records actions = convertRuleActionsToActions(r.Actions) } - + rules[i] = PermissionRule{ Actions: actions, Resources: resources, @@ -372,9 +408,9 @@ func convertTypesPermissionToRbac(tp *types.Permission) *Permission { } return &Permission{ - ID: tp.ID, // UUID + ID: tp.ID, // UUID OrgID: tp.OrgID, // ✅ FIX: Copy org_id from database model - Name: tp.Name, // Identifier like "unit-read" + Name: tp.Name, // Identifier like "unit-read" Description: tp.Description, Rules: rules, CreatedAt: tp.CreatedAt, @@ -389,9 +425,9 @@ func convertTypesRoleToRbac(tr *types.Role) *Role { } return &Role{ - ID: tr.ID, // UUID + ID: tr.ID, // UUID OrgID: tr.OrgID, // ✅ FIX: Copy org_id from database model - Name: tr.Name, // Identifier like "admin" + Name: tr.Name, // Identifier like "admin" Description: tr.Description, Permissions: permIDs, CreatedAt: tr.CreatedAt, @@ -421,7 +457,7 @@ func convertRbacRulesToTypes(rules []PermissionRule) []types.Rule { for i, r := range rules { // Marshal resource patterns to JSON resourcePatternsJSON, _ := json.Marshal(r.Resources) - + typeRules[i] = types.Rule{ Effect: r.Effect, WildcardAction: containsWildcard(actionsToStrings(r.Actions)), @@ -459,12 +495,12 @@ func extractResourcesFromRule(r *types.Rule) []string { return patterns } } - + // Fallback: check wildcard flag if r.WildcardResource { return []string{"*"} } - + // Default: no resources (deny by default) return []string{} } @@ -485,4 +521,3 @@ func containsWildcard(strs []string) bool { } return false } - diff --git a/taco/internal/rbac/rbac.go b/taco/internal/rbac/rbac.go index c096907c8..e8f4bf84f 100644 --- a/taco/internal/rbac/rbac.go +++ b/taco/internal/rbac/rbac.go @@ -13,7 +13,7 @@ import ( ) var ( - ErrNotFound = errors.New("not found") + ErrNotFound = errors.New("not found") ErrVersionConflict = errors.New("version conflict - object was modified by another operation") ) @@ -21,11 +21,11 @@ var ( type Action string const ( - ActionUnitRead Action = "unit.read" - ActionUnitWrite Action = "unit.write" - ActionUnitLock Action = "unit.lock" - ActionUnitDelete Action = "unit.delete" - ActionRBACManage Action = "rbac.manage" + ActionUnitRead Action = "unit.read" + ActionUnitWrite Action = "unit.write" + ActionUnitLock Action = "unit.lock" + ActionUnitDelete Action = "unit.delete" + ActionRBACManage Action = "rbac.manage" ) // Principal captures the caller identity and roles/groups. @@ -119,13 +119,13 @@ type RBACStore interface { // Permission management (org-scoped) CreatePermission(ctx context.Context, permission *Permission) error GetPermission(ctx context.Context, orgID, id string) (*Permission, error) - ListPermissions(ctx context.Context, orgID string) ([]*Permission, error) + ListPermissions(ctx context.Context, orgID string, page, pageSize int) ([]*Permission, int64, error) DeletePermission(ctx context.Context, orgID, id string) error // Role management (org-scoped) CreateRole(ctx context.Context, role *Role) error GetRole(ctx context.Context, orgID, id string) (*Role, error) - ListRoles(ctx context.Context, orgID string) ([]*Role, error) + ListRoles(ctx context.Context, orgID string, page, pageSize int) ([]*Role, int64, error) DeleteRole(ctx context.Context, orgID, id string) error // User assignment management (org-scoped) @@ -156,7 +156,7 @@ func NewRBACManagerFromQueryStore(queryStore interface{}) (*RBACManager, error) type dbProvider interface { GetDB() *gorm.DB } - + sqlStore, ok := queryStore.(dbProvider) if !ok { return nil, fmt.Errorf("query store does not expose GetDB() method - RBAC requires database access") @@ -175,11 +175,11 @@ func NewRBACManagerFromQueryStore(queryStore interface{}) (*RBACManager, error) func (m *RBACManager) InitializeRBAC(ctx context.Context, orgID, initUser, initEmail string) error { // For InitializeRBAC, we need explicit orgID since we're creating the org's RBAC structure // Check if already initialized for this org - enabled, err := m.store.ListPermissions(ctx, orgID) + _, totalPerms, err := m.store.ListPermissions(ctx, orgID, 1, 1) if err != nil { return fmt.Errorf("failed to check if RBAC is enabled: %w", err) } - if len(enabled) > 0 { + if totalPerms > 0 { // Already initialized - just ensure the init user has admin role if err := m.store.AssignRole(ctx, orgID, initUser, initEmail, "admin"); err != nil { // Ignore duplicate assignment errors @@ -278,12 +278,12 @@ func (m *RBACManager) IsEnabled(ctx context.Context) (bool, error) { if !ok { return false, fmt.Errorf("organization context required for RBAC") } - - perms, err := m.store.ListPermissions(ctx, orgCtx.OrgID) + + _, total, err := m.store.ListPermissions(ctx, orgCtx.OrgID, 1, 1) if err != nil { return false, fmt.Errorf("RBAC database unavailable: %w", err) } - return len(perms) > 0, nil + return total > 0, nil } // Can determines whether a principal is authorized to perform an action on a given unit key. @@ -294,7 +294,7 @@ func (m *RBACManager) Can(ctx context.Context, principal Principal, action Actio if !ok { return false, fmt.Errorf("organization context required for RBAC") } - + enabled, err := m.IsEnabled(ctx) if err != nil { return false, err @@ -400,22 +400,22 @@ func (m *RBACManager) RevokeRoleByEmail(ctx context.Context, email, roleID strin return m.store.RevokeRoleByEmail(ctx, orgCtx.OrgID, email, roleID) } -// ListRoles returns all roles for the current organization (from context). -func (m *RBACManager) ListRoles(ctx context.Context) ([]*Role, error) { +// ListRoles returns paginated roles for the current organization (from context). +func (m *RBACManager) ListRoles(ctx context.Context, page, pageSize int) ([]*Role, int64, error) { orgCtx, ok := domain.OrgFromContext(ctx) if !ok { - return nil, fmt.Errorf("organization context required") + return nil, 0, fmt.Errorf("organization context required") } - return m.store.ListRoles(ctx, orgCtx.OrgID) + return m.store.ListRoles(ctx, orgCtx.OrgID, page, pageSize) } -// ListPermissions returns all permissions for the current organization (from context). -func (m *RBACManager) ListPermissions(ctx context.Context) ([]*Permission, error) { +// ListPermissions returns paginated permissions for the current organization (from context). +func (m *RBACManager) ListPermissions(ctx context.Context, page, pageSize int) ([]*Permission, int64, error) { orgCtx, ok := domain.OrgFromContext(ctx) if !ok { - return nil, fmt.Errorf("organization context required") + return nil, 0, fmt.Errorf("organization context required") } - return m.store.ListPermissions(ctx, orgCtx.OrgID) + return m.store.ListPermissions(ctx, orgCtx.OrgID, page, pageSize) } // ListUserAssignments returns all user assignments for the current organization (from context). @@ -434,20 +434,22 @@ func (m *RBACManager) FilterUnitsByReadAccess(ctx context.Context, principal Pri if err != nil { return nil, err } - if !enabled { - return units, nil // RBAC disabled, return all units - } + if !enabled { + return units, nil // RBAC disabled, return all units + } - var filtered []string - for _, unit := range units { - canRead, err := m.Can(ctx, principal, ActionUnitRead, unit) - if err != nil { - continue // Skip on error - } - if canRead { filtered = append(filtered, unit) } - } + var filtered []string + for _, unit := range units { + canRead, err := m.Can(ctx, principal, ActionUnitRead, unit) + if err != nil { + continue // Skip on error + } + if canRead { + filtered = append(filtered, unit) + } + } - return filtered, nil + return filtered, nil } // CreatePermission creates a new permission @@ -455,8 +457,6 @@ func (m *RBACManager) CreatePermission(ctx context.Context, permission *Permissi return m.store.CreatePermission(ctx, permission) } - - // DeletePermission deletes a permission. The organization is extracted from context. func (m *RBACManager) DeletePermission(ctx context.Context, id string) error { orgCtx, ok := domain.OrgFromContext(ctx) diff --git a/taco/internal/rbac/rbac_test.go b/taco/internal/rbac/rbac_test.go index 48c873564..33b2e51ef 100644 --- a/taco/internal/rbac/rbac_test.go +++ b/taco/internal/rbac/rbac_test.go @@ -5,23 +5,30 @@ import ( "testing" "time" + "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const testOrgID = "test-org" + +func orgCtx() context.Context { + return domain.ContextWithOrg(context.Background(), testOrgID) +} + // mockRBACStore implements RBACStore for testing type mockRBACStore struct { - permissions map[string]*Permission - roles map[string]*Role - userAssignments map[string]*UserAssignment + permissions map[string]*Permission + roles map[string]*Role + userAssignments map[string]*UserAssignment userAssignmentsByEmail map[string]*UserAssignment } func newMockRBACStore() *mockRBACStore { return &mockRBACStore{ - permissions: make(map[string]*Permission), - roles: make(map[string]*Role), - userAssignments: make(map[string]*UserAssignment), + permissions: make(map[string]*Permission), + roles: make(map[string]*Role), + userAssignments: make(map[string]*UserAssignment), userAssignmentsByEmail: make(map[string]*UserAssignment), } } @@ -31,22 +38,37 @@ func (m *mockRBACStore) CreatePermission(ctx context.Context, permission *Permis return nil } -func (m *mockRBACStore) GetPermission(ctx context.Context, id string) (*Permission, error) { +func (m *mockRBACStore) GetPermission(ctx context.Context, _ string, id string) (*Permission, error) { if permission, exists := m.permissions[id]; exists { return permission, nil } return nil, ErrNotFound } -func (m *mockRBACStore) ListPermissions(ctx context.Context) ([]*Permission, error) { +func (m *mockRBACStore) ListPermissions(ctx context.Context, _ string, page, pageSize int) ([]*Permission, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } var permissions []*Permission for _, permission := range m.permissions { permissions = append(permissions, permission) } - return permissions, nil + total := int64(len(permissions)) + start := (page - 1) * pageSize + if start > len(permissions) { + return []*Permission{}, total, nil + } + end := start + pageSize + if end > len(permissions) { + end = len(permissions) + } + return permissions[start:end], total, nil } -func (m *mockRBACStore) DeletePermission(ctx context.Context, id string) error { +func (m *mockRBACStore) DeletePermission(ctx context.Context, _ string, id string) error { delete(m.permissions, id) return nil } @@ -56,27 +78,42 @@ func (m *mockRBACStore) CreateRole(ctx context.Context, role *Role) error { return nil } -func (m *mockRBACStore) GetRole(ctx context.Context, id string) (*Role, error) { +func (m *mockRBACStore) GetRole(ctx context.Context, _ string, id string) (*Role, error) { if role, exists := m.roles[id]; exists { return role, nil } return nil, ErrNotFound } -func (m *mockRBACStore) ListRoles(ctx context.Context) ([]*Role, error) { +func (m *mockRBACStore) ListRoles(ctx context.Context, _ string, page, pageSize int) ([]*Role, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } var roles []*Role for _, role := range m.roles { roles = append(roles, role) } - return roles, nil + total := int64(len(roles)) + start := (page - 1) * pageSize + if start > len(roles) { + return []*Role{}, total, nil + } + end := start + pageSize + if end > len(roles) { + end = len(roles) + } + return roles[start:end], total, nil } -func (m *mockRBACStore) DeleteRole(ctx context.Context, id string) error { +func (m *mockRBACStore) DeleteRole(ctx context.Context, _ string, id string) error { delete(m.roles, id) return nil } -func (m *mockRBACStore) AssignRole(ctx context.Context, subject, email, roleID string) error { +func (m *mockRBACStore) AssignRole(ctx context.Context, _ string, subject, email, roleID string) error { assignment := &UserAssignment{ Subject: subject, Email: email, @@ -89,7 +126,7 @@ func (m *mockRBACStore) AssignRole(ctx context.Context, subject, email, roleID s return nil } -func (m *mockRBACStore) RevokeRole(ctx context.Context, subject, roleID string) error { +func (m *mockRBACStore) RevokeRole(ctx context.Context, _ string, subject, roleID string) error { if assignment, exists := m.userAssignments[subject]; exists { var newRoles []string for _, role := range assignment.Roles { @@ -103,21 +140,21 @@ func (m *mockRBACStore) RevokeRole(ctx context.Context, subject, roleID string) return nil } -func (m *mockRBACStore) GetUserAssignment(ctx context.Context, subject string) (*UserAssignment, error) { +func (m *mockRBACStore) GetUserAssignment(ctx context.Context, _ string, subject string) (*UserAssignment, error) { if assignment, exists := m.userAssignments[subject]; exists { return assignment, nil } return nil, ErrNotFound } -func (m *mockRBACStore) GetUserAssignmentByEmail(ctx context.Context, email string) (*UserAssignment, error) { +func (m *mockRBACStore) GetUserAssignmentByEmail(ctx context.Context, _ string, email string) (*UserAssignment, error) { if assignment, exists := m.userAssignmentsByEmail[email]; exists { return assignment, nil } return nil, ErrNotFound } -func (m *mockRBACStore) ListUserAssignments(ctx context.Context) ([]*UserAssignment, error) { +func (m *mockRBACStore) ListUserAssignments(ctx context.Context, _ string) ([]*UserAssignment, error) { var assignments []*UserAssignment for _, assignment := range m.userAssignments { assignments = append(assignments, assignment) @@ -125,15 +162,15 @@ func (m *mockRBACStore) ListUserAssignments(ctx context.Context) ([]*UserAssignm return assignments, nil } -func (m *mockRBACStore) AssignRoleByEmail(ctx context.Context, email, roleID string) error { +func (m *mockRBACStore) AssignRoleByEmail(ctx context.Context, _ string, email, roleID string) error { // For testing, we'll create a mock subject subject := "test-subject-" + email - return m.AssignRole(ctx, subject, email, roleID) + return m.AssignRole(ctx, "", subject, email, roleID) } -func (m *mockRBACStore) RevokeRoleByEmail(ctx context.Context, email, roleID string) error { +func (m *mockRBACStore) RevokeRoleByEmail(ctx context.Context, _ string, email, roleID string) error { if assignment, exists := m.userAssignmentsByEmail[email]; exists { - return m.RevokeRole(ctx, assignment.Subject, roleID) + return m.RevokeRole(ctx, "", assignment.Subject, roleID) } return ErrNotFound } @@ -141,42 +178,43 @@ func (m *mockRBACStore) RevokeRoleByEmail(ctx context.Context, email, roleID str func TestRBACManager_InitializeRBAC(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() subject := "test-subject" email := "test@example.com" - err := manager.InitializeRBAC(context.Background(), subject, email) + err := manager.InitializeRBAC(ctx, testOrgID, subject, email) require.NoError(t, err) // Check that RBAC is now enabled (permissions exist) - enabled, err := manager.IsEnabled(context.Background()) + enabled, err := manager.IsEnabled(ctx) require.NoError(t, err) assert.True(t, enabled, "RBAC should be enabled after initialization") // Check that default permissions were created - adminPermission, err := store.GetPermission(context.Background(), "admin") + adminPermission, err := store.GetPermission(ctx, testOrgID, "admin") require.NoError(t, err) assert.Equal(t, "Admin Permission", adminPermission.Name) assert.Contains(t, adminPermission.Rules[0].Actions, ActionRBACManage) - defaultPermission, err := store.GetPermission(context.Background(), "default") + defaultPermission, err := store.GetPermission(ctx, testOrgID, "default") require.NoError(t, err) assert.Equal(t, "Default Permission", defaultPermission.Name) assert.Contains(t, defaultPermission.Rules[0].Actions, ActionUnitRead) // Check that default roles were created - adminRole, err := store.GetRole(context.Background(), "admin") + adminRole, err := store.GetRole(ctx, testOrgID, "admin") require.NoError(t, err) assert.Equal(t, "Admin Role", adminRole.Name) assert.Contains(t, adminRole.Permissions, "admin") - defaultRole, err := store.GetRole(context.Background(), "default") + defaultRole, err := store.GetRole(ctx, testOrgID, "default") require.NoError(t, err) assert.Equal(t, "Default Role", defaultRole.Name) assert.Contains(t, defaultRole.Permissions, "default") // Check that user was assigned roles - assignment, err := store.GetUserAssignment(context.Background(), subject) + assignment, err := store.GetUserAssignment(ctx, testOrgID, subject) require.NoError(t, err) assert.Equal(t, email, assignment.Email) assert.Contains(t, assignment.Roles, "admin") @@ -186,9 +224,10 @@ func TestRBACManager_InitializeRBAC(t *testing.T) { func TestRBACManager_IsEnabled(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Initially disabled (no permissions) - enabled, err := manager.IsEnabled(context.Background()) + enabled, err := manager.IsEnabled(ctx) require.NoError(t, err) assert.False(t, enabled, "RBAC should be disabled when no permissions exist") @@ -206,11 +245,12 @@ func TestRBACManager_IsEnabled(t *testing.T) { }, CreatedAt: time.Now(), CreatedBy: "test", + OrgID: testOrgID, } - err = store.CreatePermission(context.Background(), perm) + err = store.CreatePermission(ctx, perm) require.NoError(t, err) - enabled, err = manager.IsEnabled(context.Background()) + enabled, err = manager.IsEnabled(ctx) require.NoError(t, err) assert.True(t, enabled, "RBAC should be enabled when permissions exist") } @@ -218,6 +258,7 @@ func TestRBACManager_IsEnabled(t *testing.T) { func TestRBACManager_Can(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create a permission that allows state.read on dev/* permission := &Permission{ @@ -225,21 +266,23 @@ func TestRBACManager_Can(t *testing.T) { Name: "Dev Access", Rules: []PermissionRule{ { - Actions: []Action{ActionUnitRead, ActionUnitWrite}, + Actions: []Action{ActionUnitRead, ActionUnitWrite}, Resources: []string{"dev/*"}, Effect: "allow", }, }, + OrgID: testOrgID, } - store.CreatePermission(context.Background(), permission) + store.CreatePermission(ctx, permission) // Create a role with the permission role := &Role{ ID: "developer", Name: "Developer", Permissions: []string{"dev-access"}, + OrgID: testOrgID, } - store.CreateRole(context.Background(), role) + store.CreateRole(ctx, role) // Create a user with the role principal := Principal{ @@ -249,25 +292,25 @@ func TestRBACManager_Can(t *testing.T) { } // Assign the role to the user - err := manager.AssignRole(context.Background(), principal.Subject, principal.Email, "developer") + err := manager.AssignRole(ctx, principal.Subject, principal.Email, "developer") require.NoError(t, err) // Test allowed access -can, err := manager.Can(context.Background(), principal, ActionUnitRead, "dev/myapp") + can, err := manager.Can(ctx, principal, ActionUnitRead, "dev/myapp") require.NoError(t, err) assert.True(t, can) -can, err = manager.Can(context.Background(), principal, ActionUnitWrite, "dev/myapp") + can, err = manager.Can(ctx, principal, ActionUnitWrite, "dev/myapp") require.NoError(t, err) assert.True(t, can) // Test denied access (different resource) -can, err = manager.Can(context.Background(), principal, ActionUnitRead, "prod/myapp") + can, err = manager.Can(ctx, principal, ActionUnitRead, "prod/myapp") require.NoError(t, err) assert.False(t, can) // Test denied access (different action) -can, err = manager.Can(context.Background(), principal, ActionUnitDelete, "dev/myapp") + can, err = manager.Can(ctx, principal, ActionUnitDelete, "dev/myapp") require.NoError(t, err) assert.False(t, can) } @@ -275,6 +318,7 @@ can, err = manager.Can(context.Background(), principal, ActionUnitDelete, "dev/m func TestRBACManager_CanWithDenyRule(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create a permission with both allow and deny rules permission := &Permission{ @@ -282,26 +326,28 @@ func TestRBACManager_CanWithDenyRule(t *testing.T) { Name: "Mixed Access", Rules: []PermissionRule{ { - Actions: []Action{ActionUnitRead, ActionUnitWrite}, + Actions: []Action{ActionUnitRead, ActionUnitWrite}, Resources: []string{"dev/*"}, Effect: "allow", }, { - Actions: []Action{ActionUnitDelete}, + Actions: []Action{ActionUnitDelete}, Resources: []string{"dev/prod"}, Effect: "deny", }, }, + OrgID: testOrgID, } - store.CreatePermission(context.Background(), permission) + store.CreatePermission(ctx, permission) // Create a role with the permission role := &Role{ ID: "developer", Name: "Developer", Permissions: []string{"mixed-access"}, + OrgID: testOrgID, } - store.CreateRole(context.Background(), role) + store.CreateRole(ctx, role) // Create a user with the role principal := Principal{ @@ -311,16 +357,16 @@ func TestRBACManager_CanWithDenyRule(t *testing.T) { } // Assign the role to the user - err := manager.AssignRole(context.Background(), principal.Subject, principal.Email, "developer") + err := manager.AssignRole(ctx, principal.Subject, principal.Email, "developer") require.NoError(t, err) // Test allowed access -can, err := manager.Can(context.Background(), principal, ActionUnitRead, "dev/myapp") + can, err := manager.Can(ctx, principal, ActionUnitRead, "dev/myapp") require.NoError(t, err) assert.True(t, can) // Test denied access (explicit deny rule) -can, err = manager.Can(context.Background(), principal, ActionUnitDelete, "dev/prod") + can, err = manager.Can(ctx, principal, ActionUnitDelete, "dev/prod") require.NoError(t, err) assert.False(t, can) } @@ -328,6 +374,7 @@ can, err = manager.Can(context.Background(), principal, ActionUnitDelete, "dev/p func TestRBACManager_CreateRole(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() role := &Role{ ID: "test-role", @@ -335,13 +382,14 @@ func TestRBACManager_CreateRole(t *testing.T) { Description: "A test role", Permissions: []string{"permission1", "permission2"}, CreatedBy: "test-user", + OrgID: testOrgID, } - err := manager.CreateRole(context.Background(), role) + err := manager.CreateRole(ctx, role) require.NoError(t, err) // Verify role was created - createdRole, err := store.GetRole(context.Background(), "test-role") + createdRole, err := store.GetRole(ctx, testOrgID, "test-role") require.NoError(t, err) assert.Equal(t, "Test Role", createdRole.Name) assert.Equal(t, []string{"permission1", "permission2"}, createdRole.Permissions) @@ -350,16 +398,17 @@ func TestRBACManager_CreateRole(t *testing.T) { func TestRBACManager_AssignRole(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() subject := "test-user" email := "test@example.com" roleID := "developer" - err := manager.AssignRole(context.Background(), subject, email, roleID) + err := manager.AssignRole(ctx, subject, email, roleID) require.NoError(t, err) // Verify assignment was created - assignment, err := store.GetUserAssignment(context.Background(), subject) + assignment, err := store.GetUserAssignment(ctx, testOrgID, subject) require.NoError(t, err) assert.Equal(t, email, assignment.Email) assert.Contains(t, assignment.Roles, roleID) @@ -368,15 +417,16 @@ func TestRBACManager_AssignRole(t *testing.T) { func TestRBACManager_AssignRoleByEmail(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() email := "test@example.com" roleID := "developer" - err := manager.AssignRoleByEmail(context.Background(), email, roleID) + err := manager.AssignRoleByEmail(ctx, email, roleID) require.NoError(t, err) // Verify assignment was created - assignment, err := store.GetUserAssignmentByEmail(context.Background(), email) + assignment, err := store.GetUserAssignmentByEmail(ctx, testOrgID, email) require.NoError(t, err) assert.Equal(t, email, assignment.Email) assert.Contains(t, assignment.Roles, roleID) @@ -385,21 +435,22 @@ func TestRBACManager_AssignRoleByEmail(t *testing.T) { func TestRBACManager_RevokeRole(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() subject := "test-user" email := "test@example.com" roleID := "developer" // First assign the role - err := manager.AssignRole(context.Background(), subject, email, roleID) + err := manager.AssignRole(ctx, subject, email, roleID) require.NoError(t, err) // Then revoke it - err = manager.RevokeRole(context.Background(), subject, roleID) + err = manager.RevokeRole(ctx, subject, roleID) require.NoError(t, err) // Verify role was revoked - assignment, err := store.GetUserAssignment(context.Background(), subject) + assignment, err := store.GetUserAssignment(ctx, testOrgID, subject) require.NoError(t, err) assert.NotContains(t, assignment.Roles, roleID) } @@ -407,15 +458,17 @@ func TestRBACManager_RevokeRole(t *testing.T) { func TestRBACManager_ListRoles(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create some roles - role1 := &Role{ID: "role1", Name: "Role 1"} - role2 := &Role{ID: "role2", Name: "Role 2"} - store.CreateRole(context.Background(), role1) - store.CreateRole(context.Background(), role2) + role1 := &Role{ID: "role1", Name: "Role 1", OrgID: testOrgID} + role2 := &Role{ID: "role2", Name: "Role 2", OrgID: testOrgID} + store.CreateRole(ctx, role1) + store.CreateRole(ctx, role2) - roles, err := manager.ListRoles(context.Background()) + roles, total, err := manager.ListRoles(ctx, 1, 50) require.NoError(t, err) + assert.Equal(t, int64(2), total) assert.Len(t, roles, 2) roleIDs := make(map[string]bool) @@ -429,15 +482,17 @@ func TestRBACManager_ListRoles(t *testing.T) { func TestRBACManager_ListPermissions(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create some permissions - permission1 := &Permission{ID: "permission1", Name: "Permission 1"} - permission2 := &Permission{ID: "permission2", Name: "Permission 2"} - store.CreatePermission(context.Background(), permission1) - store.CreatePermission(context.Background(), permission2) + permission1 := &Permission{ID: "permission1", Name: "Permission 1", OrgID: testOrgID} + permission2 := &Permission{ID: "permission2", Name: "Permission 2", OrgID: testOrgID} + store.CreatePermission(ctx, permission1) + store.CreatePermission(ctx, permission2) - permissions, err := manager.ListPermissions(context.Background()) + permissions, total, err := manager.ListPermissions(ctx, 1, 50) require.NoError(t, err) + assert.Equal(t, int64(2), total) assert.Len(t, permissions, 2) permissionIDs := make(map[string]bool) @@ -451,6 +506,7 @@ func TestRBACManager_ListPermissions(t *testing.T) { func TestRBACManager_ListUserAssignments(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create some assignments assignment1 := &UserAssignment{ @@ -466,7 +522,7 @@ func TestRBACManager_ListUserAssignments(t *testing.T) { store.userAssignments["user1"] = assignment1 store.userAssignments["user2"] = assignment2 - assignments, err := manager.ListUserAssignments(context.Background()) + assignments, err := manager.ListUserAssignments(ctx) require.NoError(t, err) assert.Len(t, assignments, 2) @@ -481,6 +537,7 @@ func TestRBACManager_ListUserAssignments(t *testing.T) { func TestRBACManager_FilterUnitsByReadAccess(t *testing.T) { store := newMockRBACStore() manager := NewRBACManager(store) + ctx := orgCtx() // Create a permission that allows read access to dev/* permission := &Permission{ @@ -488,21 +545,23 @@ func TestRBACManager_FilterUnitsByReadAccess(t *testing.T) { Name: "Dev Read", Rules: []PermissionRule{ { - Actions: []Action{ActionUnitRead}, + Actions: []Action{ActionUnitRead}, Resources: []string{"dev/*"}, Effect: "allow", }, }, + OrgID: testOrgID, } - store.CreatePermission(context.Background(), permission) + store.CreatePermission(ctx, permission) // Create a role with the permission role := &Role{ ID: "developer", Name: "Developer", Permissions: []string{"dev-read"}, + OrgID: testOrgID, } - store.CreateRole(context.Background(), role) + store.CreateRole(ctx, role) // Create a user with the role principal := Principal{ @@ -512,26 +571,26 @@ func TestRBACManager_FilterUnitsByReadAccess(t *testing.T) { } // Assign the role to the user - err := manager.AssignRole(context.Background(), principal.Subject, principal.Email, "developer") + err := manager.AssignRole(ctx, principal.Subject, principal.Email, "developer") require.NoError(t, err) - // Test units - units := []string{ - "dev/app1", - "dev/app2", - "prod/app1", - "staging/app1", - } + // Test units + units := []string{ + "dev/app1", + "dev/app2", + "prod/app1", + "staging/app1", + } - filtered, err := manager.FilterUnitsByReadAccess(context.Background(), principal, units) - require.NoError(t, err) + filtered, err := manager.FilterUnitsByReadAccess(ctx, principal, units) + require.NoError(t, err) - // Should only include dev/* units - assert.Len(t, filtered, 2) - assert.Contains(t, filtered, "dev/app1") - assert.Contains(t, filtered, "dev/app2") - assert.NotContains(t, filtered, "prod/app1") - assert.NotContains(t, filtered, "staging/app1") + // Should only include dev/* units + assert.Len(t, filtered, 2) + assert.Contains(t, filtered, "dev/app1") + assert.Contains(t, filtered, "dev/app2") + assert.NotContains(t, filtered, "prod/app1") + assert.NotContains(t, filtered, "staging/app1") } func TestPermissionRule_Matches(t *testing.T) { @@ -542,14 +601,14 @@ func TestPermissionRule_Matches(t *testing.T) { } // Test action matching -assert.True(t, rule.matches(ActionUnitRead, "dev/app")) -assert.True(t, rule.matches(ActionUnitWrite, "staging/app")) -assert.False(t, rule.matches(ActionUnitDelete, "dev/app")) + assert.True(t, rule.matches(ActionUnitRead, "dev/app")) + assert.True(t, rule.matches(ActionUnitWrite, "staging/app")) + assert.False(t, rule.matches(ActionUnitDelete, "dev/app")) // Test resource matching with wildcards -assert.True(t, rule.matches(ActionUnitRead, "dev/myapp")) -assert.True(t, rule.matches(ActionUnitRead, "staging/myapp")) -assert.False(t, rule.matches(ActionUnitRead, "prod/myapp")) + assert.True(t, rule.matches(ActionUnitRead, "dev/myapp")) + assert.True(t, rule.matches(ActionUnitRead, "staging/myapp")) + assert.False(t, rule.matches(ActionUnitRead, "prod/myapp")) // Test exact resource matching exactRule := PermissionRule{ @@ -557,8 +616,8 @@ assert.False(t, rule.matches(ActionUnitRead, "prod/myapp")) Resources: []string{"myapp/prod"}, Effect: "allow", } -assert.True(t, exactRule.matches(ActionUnitRead, "myapp/prod")) -assert.False(t, exactRule.matches(ActionUnitRead, "myapp/staging")) + assert.True(t, exactRule.matches(ActionUnitRead, "myapp/prod")) + assert.False(t, exactRule.matches(ActionUnitRead, "myapp/staging")) } func TestPermissionRule_MatchesWildcard(t *testing.T) { @@ -569,9 +628,9 @@ func TestPermissionRule_MatchesWildcard(t *testing.T) { } // Should match any resource -assert.True(t, rule.matches(ActionUnitRead, "any/resource")) -assert.True(t, rule.matches(ActionUnitRead, "dev/app")) -assert.True(t, rule.matches(ActionUnitRead, "prod/app")) + assert.True(t, rule.matches(ActionUnitRead, "any/resource")) + assert.True(t, rule.matches(ActionUnitRead, "dev/app")) + assert.True(t, rule.matches(ActionUnitRead, "prod/app")) } func TestPermissionRule_MatchesActionWildcard(t *testing.T) { @@ -582,8 +641,8 @@ func TestPermissionRule_MatchesActionWildcard(t *testing.T) { } // Should match any action on dev/* resources -assert.True(t, rule.matches(ActionUnitRead, "dev/app")) -assert.True(t, rule.matches(ActionUnitWrite, "dev/app")) -assert.True(t, rule.matches(ActionUnitDelete, "dev/app")) -assert.False(t, rule.matches(ActionUnitRead, "prod/app")) + assert.True(t, rule.matches(ActionUnitRead, "dev/app")) + assert.True(t, rule.matches(ActionUnitWrite, "dev/app")) + assert.True(t, rule.matches(ActionUnitDelete, "dev/app")) + assert.False(t, rule.matches(ActionUnitRead, "prod/app")) } diff --git a/taco/internal/repositories/unit_repository.go b/taco/internal/repositories/unit_repository.go index 06438777c..ccc3d77ec 100644 --- a/taco/internal/repositories/unit_repository.go +++ b/taco/internal/repositories/unit_repository.go @@ -53,7 +53,7 @@ func (r *UnitRepository) Create(ctx context.Context, orgID, name string) (*stora err := r.db.WithContext(ctx). Where("org_id = ? AND name = ?", orgID, name). First(&existing).Error - + if err == nil { // Unit already exists - this is expected behavior, not an error return nil, storage.ErrAlreadyExists @@ -76,10 +76,10 @@ func (r *UnitRepository) Create(ctx context.Context, orgID, name string) (*stora // Check if this is a unique constraint violation // GORM doesn't have a specific error type, so we check the error string errMsg := err.Error() - if strings.Contains(errMsg, "duplicate") || - strings.Contains(errMsg, "unique constraint") || - strings.Contains(errMsg, "UNIQUE constraint") || - strings.Contains(errMsg, "idx_units_org_name") { + if strings.Contains(errMsg, "duplicate") || + strings.Contains(errMsg, "unique constraint") || + strings.Contains(errMsg, "UNIQUE constraint") || + strings.Contains(errMsg, "idx_units_org_name") { return nil, storage.ErrAlreadyExists } return nil, fmt.Errorf("failed to create unit in database: %w", err) @@ -102,13 +102,13 @@ func (r *UnitRepository) Create(ctx context.Context, orgID, name string) (*stora }() return &storage.UnitMetadata{ - ID: unit.ID, // UUID - Name: name, // Short name - OrgID: orgID, // Org UUID - OrgName: org.Name, // Org short name (e.g., "acme") - Size: unit.Size, - Updated: unit.UpdatedAt, - Locked: unit.Locked, + ID: unit.ID, // UUID + Name: name, // Short name + OrgID: orgID, // Org UUID + OrgName: org.Name, // Org short name (e.g., "acme") + Size: unit.Size, + Updated: unit.UpdatedAt, + Locked: unit.Locked, }, nil } @@ -140,15 +140,15 @@ func (r *UnitRepository) Get(ctx context.Context, uuid string) (*storage.UnitMet // Merge database and blob metadata meta := &storage.UnitMetadata{ - ID: unit.ID, - Name: unit.Name, - OrgID: unit.OrgID, - OrgName: org.Name, - Size: unit.Size, - Updated: unit.UpdatedAt, - Locked: unit.Locked, - LockID: unit.LockID, - + ID: unit.ID, + Name: unit.Name, + OrgID: unit.OrgID, + OrgName: org.Name, + Size: unit.Size, + Updated: unit.UpdatedAt, + Locked: unit.Locked, + LockID: unit.LockID, + // Include TFE workspace settings TFEAutoApply: unit.TFEAutoApply, TFETerraformVersion: unit.TFETerraformVersion, @@ -170,11 +170,14 @@ func (r *UnitRepository) Get(ctx context.Context, uuid string) (*storage.UnitMet func (r *UnitRepository) List(ctx context.Context, orgID, prefix string) ([]*storage.UnitMetadata, error) { var units []types.Unit query := r.db.WithContext(ctx).Where("org_id = ?", orgID) - + if prefix != "" { query = query.Where("name LIKE ?", prefix+"%") } - + + // Order by name for stable pagination across pages; tie-break by ID + query = query.Order("LOWER(name) ASC").Order("id ASC") + if err := query.Find(&units).Error; err != nil { return nil, fmt.Errorf("failed to list units: %w", err) } @@ -195,13 +198,13 @@ func (r *UnitRepository) List(ctx context.Context, orgID, prefix string) ([]*sto result := make([]*storage.UnitMetadata, len(units)) for i, unit := range units { result[i] = &storage.UnitMetadata{ - ID: unit.ID, - Name: unit.Name, - OrgID: unit.OrgID, - OrgName: orgName, - Size: unit.Size, - Updated: unit.UpdatedAt, - Locked: unit.Locked, + ID: unit.ID, + Name: unit.Name, + OrgID: unit.OrgID, + OrgName: orgName, + Size: unit.Size, + Updated: unit.UpdatedAt, + Locked: unit.Locked, } } @@ -238,7 +241,7 @@ func (r *UnitRepository) Delete(ctx context.Context, uuid string) error { return fmt.Errorf("failed to delete unit from database: %w", err) } - log.Printf("Deleted unit: UUID=%s, Org=%s (%s), Name=%s, BlobPath=%s", + log.Printf("Deleted unit: UUID=%s, Org=%s (%s), Name=%s, BlobPath=%s", uuid, org.Name, org.ID, unit.Name, blobPath) return nil } diff --git a/taco/internal/token_service/handler.go b/taco/internal/token_service/handler.go index 7fbc3e28f..4e5c09c9d 100644 --- a/taco/internal/token_service/handler.go +++ b/taco/internal/token_service/handler.go @@ -4,8 +4,9 @@ import ( "net/http" "time" - "github.com/diggerhq/digger/opentaco/internal/logging" querytypes "github.com/diggerhq/digger/opentaco/cmd/token_service/query/types" + "github.com/diggerhq/digger/opentaco/internal/logging" + "github.com/diggerhq/digger/opentaco/internal/pagination" "github.com/labstack/echo/v4" ) @@ -41,6 +42,15 @@ type TokenResponse struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +// TokenListResponse wraps paginated token results. +type TokenListResponse struct { + Tokens []TokenResponse `json:"tokens"` + Count int `json:"count"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + // VerifyTokenRequest represents the request to verify a token type VerifyTokenRequest struct { Token string `json:"token" validate:"required"` @@ -89,17 +99,18 @@ func (h *Handler) CreateToken(c echo.Context) error { func (h *Handler) ListTokens(c echo.Context) error { userID := c.QueryParam("user_id") orgID := c.QueryParam("org_id") + pageParams := pagination.Parse(c, 25, 200) - tokens, err := h.repo.ListTokens(c.Request().Context(), userID, orgID) + tokens, total, err := h.repo.ListTokens(c.Request().Context(), userID, orgID, pageParams.Page, pageParams.PageSize) if err != nil { logger := logging.FromContext(c) logger.Error("Failed to list tokens", "user_id", userID, "org_id", orgID, "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to list tokens"}) } - responses := make([]TokenResponse, len(tokens)) - for i, token := range tokens { - responses[i] = toTokenResponseHidden(token) // Hide token hash + responses := make([]TokenResponse, 0, len(tokens)) + for _, token := range tokens { + responses = append(responses, toTokenResponseHidden(token)) // Hide token hash } // Prevent caching of token list responses @@ -107,7 +118,13 @@ func (h *Handler) ListTokens(c echo.Context) error { c.Response().Header().Set("Pragma", "no-cache") c.Response().Header().Set("Expires", "0") - return c.JSON(http.StatusOK, responses) + return c.JSON(http.StatusOK, TokenListResponse{ + Tokens: responses, + Count: len(responses), + Total: total, + Page: pageParams.Page, + PageSize: pageParams.PageSize, + }) } // DeleteToken deletes a token by ID @@ -196,14 +213,13 @@ func toTokenResponse(token *querytypes.Token) TokenResponse { // Shows last 5 chars of hash for identification (e.g., "abc12") func toTokenResponseHidden(token *querytypes.Token) TokenResponse { resp := toTokenResponse(token) - + // Show last 5 chars of hash for identification if len(token.Token) > 5 { resp.Token = token.Token[len(token.Token)-5:] } else { resp.Token = "" // Empty if hash is too short (shouldn't happen) } - + return resp } - diff --git a/taco/internal/token_service/repository.go b/taco/internal/token_service/repository.go index db756eaff..a2d41e5cf 100644 --- a/taco/internal/token_service/repository.go +++ b/taco/internal/token_service/repository.go @@ -62,10 +62,18 @@ func (r *TokenRepository) CreateToken(ctx context.Context, userID, orgID, name s return token, nil } -// ListTokens returns all tokens for a given user ID and org -func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string) ([]*types.Token, error) { +// ListTokens returns tokens for a given user ID and org with pagination. +func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string, page, pageSize int) ([]*types.Token, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + offset := (page - 1) * pageSize + var tokens []*types.Token - query := r.db.WithContext(ctx) + query := r.db.WithContext(ctx).Model(&types.Token{}) // Filter by userID if provided if userID != "" { @@ -77,11 +85,17 @@ func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string) query = query.Where("org_id = ?", orgID) } - if err := query.Order("created_at DESC").Find(&tokens).Error; err != nil { - return nil, fmt.Errorf("failed to list tokens: %w", err) + // Count total matching tokens (without pagination) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count tokens: %w", err) } - return tokens, nil + if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&tokens).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list tokens: %w", err) + } + + return tokens, total, nil } // DeleteToken deletes a token by ID @@ -100,7 +114,7 @@ func (r *TokenRepository) DeleteToken(ctx context.Context, tokenID string) error func (r *TokenRepository) VerifyToken(ctx context.Context, tokenValue, userID, orgID string) (*types.Token, error) { // Hash the provided token to compare with stored hash tokenHash := hashToken(tokenValue) - + var token types.Token query := r.db.WithContext(ctx).Where("token = ?", tokenHash) @@ -165,4 +179,3 @@ func hashToken(token string) string { hash := sha256.Sum256([]byte(token)) return hex.EncodeToString(hash[:]) } - diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index 4a52ea159..1f713bea5 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/deps" "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/diggerhq/digger/opentaco/internal/logging" + "github.com/diggerhq/digger/opentaco/internal/pagination" "github.com/diggerhq/digger/opentaco/internal/query" "github.com/diggerhq/digger/opentaco/internal/rbac" "github.com/diggerhq/digger/opentaco/internal/storage" @@ -23,7 +25,7 @@ import ( // Handler serves the management API (unit CRUD and locking). type Handler struct { store domain.UnitManagement - blobStore storage.UnitStore // For legacy deps like ComputeUnitStatus + blobStore storage.UnitStore // For legacy deps like ComputeUnitStatus rbacManager *rbac.RBACManager signer *auth.Signer queryStore query.Store @@ -48,26 +50,26 @@ func (h *Handler) resolveUnitIdentifier(ctx context.Context, identifier string) if err != nil { return "", err } - + normalized := domain.DecodeUnitID(decoded) - + // If already a UUID, return as-is if domain.IsUUID(normalized) { return normalized, nil } - + // If resolver is not available, return normalized name (will fail at repository layer) if h.resolver == nil { return normalized, nil } - + // Get org from context for resolution orgCtx, ok := domain.OrgFromContext(ctx) if !ok { // No org context, return normalized name return normalized, nil } - + // Resolve name to UUID using the identifier resolver // Note: ResolveUnit signature is (ctx, identifier, orgID) uuid, err := h.resolver.ResolveUnit(ctx, normalized, orgCtx.OrgID) @@ -75,17 +77,17 @@ func (h *Handler) resolveUnitIdentifier(ctx context.Context, identifier string) // If resolution fails, return error return "", err } - + return uuid, nil } type CreateUnitRequest struct { - Name string `json:"name"` - TFEAutoApply *bool `json:"tfe_auto_apply"` - TFEExecutionMode *string `json:"tfe_execution_mode"` - TFETerraformVersion *string `json:"tfe_terraform_version"` - TFEEngine *string `json:"tfe_engine"` - TFEWorkingDirectory *string `json:"tfe_working_directory"` + Name string `json:"name"` + TFEAutoApply *bool `json:"tfe_auto_apply"` + TFEExecutionMode *string `json:"tfe_execution_mode"` + TFETerraformVersion *string `json:"tfe_terraform_version"` + TFEEngine *string `json:"tfe_engine"` + TFEWorkingDirectory *string `json:"tfe_working_directory"` } type CreateUnitResponse struct { @@ -145,7 +147,7 @@ func (h *Handler) CreateUnit(c echo.Context) error { ) analytics.SendEssential("unit_create_failed_already_exists") return c.JSON(http.StatusConflict, map[string]string{ - "error": "Unit already exists", + "error": "Unit already exists", "detail": fmt.Sprintf("A unit with name '%s' already exists in this organization", name), }) } @@ -157,7 +159,7 @@ func (h *Handler) CreateUnit(c echo.Context) error { ) analytics.SendEssential("unit_create_failed_storage_error") return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": "Failed to create unit", + "error": "Failed to create unit", "detail": err.Error(), }) } @@ -193,11 +195,11 @@ func (h *Handler) CreateUnit(c echo.Context) error { } type UpdateUnitRequest struct { - TFEAutoApply *bool `json:"tfe_auto_apply"` - TFEExecutionMode *string `json:"tfe_execution_mode"` - TFETerraformVersion *string `json:"tfe_terraform_version"` - TFEEngine *string `json:"tfe_engine"` - TFEWorkingDirectory *string `json:"tfe_working_directory"` + TFEAutoApply *bool `json:"tfe_auto_apply"` + TFEExecutionMode *string `json:"tfe_execution_mode"` + TFETerraformVersion *string `json:"tfe_terraform_version"` + TFEEngine *string `json:"tfe_engine"` + TFEWorkingDirectory *string `json:"tfe_working_directory"` } func (h *Handler) UpdateUnit(c echo.Context) error { @@ -259,7 +261,7 @@ func (h *Handler) UpdateUnit(c echo.Context) error { "unit_id", unitID, "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": "Failed to update unit settings", + "error": "Failed to update unit settings", "detail": err.Error(), }) } @@ -272,7 +274,7 @@ func (h *Handler) UpdateUnit(c echo.Context) error { "org_id", orgCtx.OrgID) return c.JSON(http.StatusOK, map[string]interface{}{ - "id": unitID, + "id": unitID, "message": "Unit updated successfully", }) } @@ -281,6 +283,7 @@ func (h *Handler) ListUnits(c echo.Context) error { logger := logging.FromContext(c) ctx := c.Request().Context() prefix := c.QueryParam("prefix") + pageParams := pagination.Parse(c, 50, 200) // Get org UUID from domain context (set by middleware for both JWT and webhook routes) orgCtx, ok := domain.OrgFromContext(ctx) @@ -309,7 +312,7 @@ func (h *Handler) ListUnits(c echo.Context) error { return c.JSON(http.StatusForbidden, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": "Failed to list units", + "error": "Failed to list units", "detail": err.Error(), }) } @@ -320,7 +323,7 @@ func (h *Handler) ListUnits(c echo.Context) error { if u.OrgName != "" { absoluteName = domain.BuildAbsoluteName(u.OrgName, u.Name) } - + domainUnits = append(domainUnits, &domain.Unit{ ID: u.ID, Name: u.Name, @@ -331,15 +334,31 @@ func (h *Handler) ListUnits(c echo.Context) error { LockInfo: convertLockInfo(u.LockInfo), }) } - domain.SortUnitsByID(domainUnits) + sort.Slice(domainUnits, func(i, j int) bool { + return strings.ToLower(domainUnits[i].Name) < strings.ToLower(domainUnits[j].Name) + }) + + total := len(domainUnits) + start := pageParams.Offset() + if start > total { + start = total + } + end := start + pageParams.PageSize + if end > total { + end = total + } + pagedUnits := domainUnits[start:end] logger.Info("Units listed successfully", "operation", "list_units", - "count", len(domainUnits), + "count", len(pagedUnits), ) return c.JSON(http.StatusOK, map[string]interface{}{ - "units": domainUnits, - "count": len(domainUnits), + "units": pagedUnits, + "count": len(pagedUnits), + "total": total, + "page": pageParams.Page, + "page_size": pageParams.PageSize, }) } @@ -347,13 +366,13 @@ func (h *Handler) GetUnit(c echo.Context) error { logger := logging.FromContext(c) ctx := c.Request().Context() encodedID := c.Param("id") - + logger.Info("🔍 GetUnit called", "operation", "get_unit", "encoded_id", encodedID, "headers", c.Request().Header, ) - + id, err := h.resolveUnitIdentifier(ctx, encodedID) if err != nil { logger.Warn("Unit not found during resolution", @@ -362,16 +381,16 @@ func (h *Handler) GetUnit(c echo.Context) error { "error", err, ) return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } - + logger.Info("🔍 Unit identifier resolved", "operation", "get_unit", "resolved_id", id, ) - + if err := domain.ValidateUnitID(id); err != nil { logger.Warn("Invalid unit ID", "operation", "get_unit", @@ -416,12 +435,12 @@ func (h *Handler) GetUnit(c echo.Context) error { ) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get unit"}) } - + absoluteName := metadata.Name if metadata.OrgName != "" { absoluteName = domain.BuildAbsoluteName(metadata.OrgName, metadata.Name) } - + return c.JSON(http.StatusOK, &domain.Unit{ ID: metadata.ID, Name: metadata.Name, @@ -430,7 +449,7 @@ func (h *Handler) GetUnit(c echo.Context) error { Updated: metadata.Updated, Locked: metadata.Locked, LockInfo: convertLockInfo(metadata.LockInfo), - + // Include TFE workspace settings TFEAutoApply: metadata.TFEAutoApply, TFETerraformVersion: metadata.TFETerraformVersion, @@ -452,7 +471,7 @@ func (h *Handler) DeleteUnit(c echo.Context) error { "error", err, ) return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -464,12 +483,12 @@ func (h *Handler) DeleteUnit(c echo.Context) error { ) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - + logger.Info("Deleting unit", "operation", "delete_unit", "unit_id", id, ) - + if err := h.store.Delete(c.Request().Context(), id); err != nil { if err == storage.ErrNotFound { logger.Info("Unit not found for delete", @@ -485,7 +504,7 @@ func (h *Handler) DeleteUnit(c echo.Context) error { ) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete unit"}) } - + logger.Info("Unit deleted successfully", "operation", "delete_unit", "unit_id", id, @@ -508,7 +527,7 @@ func (h *Handler) DownloadUnit(c echo.Context) error { ) analytics.SendEssential("taco_unit_pull_failed") return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -521,12 +540,12 @@ func (h *Handler) DownloadUnit(c echo.Context) error { analytics.SendEssential("taco_unit_pull_failed") return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - + logger.Info("Downloading unit", "operation", "download_unit", "unit_id", id, ) - + data, err := h.store.Download(ctx, id) if err != nil { analytics.SendEssential("taco_unit_pull_failed") @@ -544,7 +563,7 @@ func (h *Handler) DownloadUnit(c echo.Context) error { ) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to download unit"}) } - + logger.Info("Unit downloaded successfully", "operation", "download_unit", "unit_id", id, @@ -569,7 +588,7 @@ func (h *Handler) UploadUnit(c echo.Context) error { ) analytics.SendEssential("taco_unit_push_failed") return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -593,14 +612,14 @@ func (h *Handler) UploadUnit(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Failed to read request body"}) } lockID := c.QueryParam("if_locked_by") - + logger.Info("Uploading unit", "operation", "upload_unit", "unit_id", id, "lock_id", lockID, "size_bytes", len(data), ) - + if err := h.store.Upload(c.Request().Context(), id, data, lockID); err != nil { analytics.SendEssential("taco_unit_push_failed") if err == storage.ErrNotFound { @@ -630,7 +649,7 @@ func (h *Handler) UploadUnit(c echo.Context) error { // commenting out for now until this functionality is fixed // Best-effort dependency graph update //go deps.UpdateGraphOnWrite(c.Request().Context(), h.store, id, data) - + logger.Info("Unit uploaded successfully", "operation", "upload_unit", "unit_id", id, @@ -658,7 +677,7 @@ func (h *Handler) LockUnit(c echo.Context) error { ) return c.JSON(http.StatusNotFound, map[string]string{"error": "Unit not found"}) } - + if err := domain.ValidateUnitID(id); err != nil { logger.Warn("Invalid unit ID for lock", "operation", "lock_unit", @@ -674,14 +693,14 @@ func (h *Handler) LockUnit(c echo.Context) error { req.Version = "1.0.0" } lockInfo := &storage.LockInfo{ID: req.ID, Who: req.Who, Version: req.Version, Created: time.Now()} - + logger.Info("Locking unit", "operation", "lock_unit", "unit_id", id, "lock_id", lockInfo.ID, "who", lockInfo.Who, ) - + if err := h.store.Lock(c.Request().Context(), id, lockInfo); err != nil { if err == storage.ErrNotFound { logger.Info("Unit not found for lock", @@ -708,7 +727,7 @@ func (h *Handler) LockUnit(c echo.Context) error { ) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to lock unit"}) } - + logger.Info("Unit locked successfully", "operation", "lock_unit", "unit_id", id, @@ -734,7 +753,7 @@ func (h *Handler) UnlockUnit(c echo.Context) error { ) return c.JSON(http.StatusNotFound, map[string]string{"error": "Unit not found"}) } - + if err := domain.ValidateUnitID(id); err != nil { logger.Warn("Invalid unit ID for unlock", "operation", "unlock_unit", @@ -751,13 +770,13 @@ func (h *Handler) UnlockUnit(c echo.Context) error { ) return c.JSON(http.StatusBadRequest, map[string]string{"error": "Lock ID required"}) } - + logger.Info("Unlocking unit", "operation", "unlock_unit", "unit_id", id, "lock_id", req.ID, ) - + if err := h.store.Unlock(c.Request().Context(), id, req.ID); err != nil { if err == storage.ErrNotFound { logger.Info("Unit not found for unlock", @@ -782,7 +801,7 @@ func (h *Handler) UnlockUnit(c echo.Context) error { ) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to unlock unit"}) } - + logger.Info("Unit unlocked successfully", "operation", "unlock_unit", "unit_id", id, @@ -811,7 +830,7 @@ func (h *Handler) ListVersions(c echo.Context) error { "error", err, ) return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -890,7 +909,7 @@ func (h *Handler) RestoreVersion(c echo.Context) error { "error", err, ) return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -966,7 +985,7 @@ func (h *Handler) GetUnitStatus(c echo.Context) error { "error", err, ) return c.JSON(http.StatusNotFound, map[string]string{ - "error": "Unit not found", + "error": "Unit not found", "detail": err.Error(), }) } @@ -994,7 +1013,7 @@ func (h *Handler) GetUnitStatus(c echo.Context) error { // On errors, prefer a 200 with green/empty as per implementation notes return c.JSON(http.StatusOK, st) } - + logger.Info("Unit status retrieved successfully", "operation", "get_unit_status", "unit_id", id, diff --git a/taco/pkg/sdk/client.go b/taco/pkg/sdk/client.go index 8d9e62caf..a46ee2f56 100644 --- a/taco/pkg/sdk/client.go +++ b/taco/pkg/sdk/client.go @@ -8,25 +8,26 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" ) // Client is the OpenTaco SDK client type Client struct { - baseURL string - httpClient *http.Client - authToken string + baseURL string + httpClient *http.Client + authToken string } // NewClient creates a new OpenTaco client func NewClient(baseURL string) *Client { - return &Client{ - baseURL: baseURL, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } } // NewClientWithHTTPClient creates a new client with a custom HTTP client @@ -39,11 +40,11 @@ func NewClientWithHTTPClient(baseURL string, httpClient *http.Client) *Client { // UnitMetadata represents unit metadata type UnitMetadata struct { - ID string `json:"id"` - Size int64 `json:"size"` - Updated time.Time `json:"updated"` - Locked bool `json:"locked"` - LockInfo *LockInfo `json:"lock,omitempty"` + ID string `json:"id"` + Size int64 `json:"size"` + Updated time.Time `json:"updated"` + Locked bool `json:"locked"` + LockInfo *LockInfo `json:"lock,omitempty"` } // LockInfo represents lock information @@ -63,26 +64,29 @@ type Version struct { // CreateUnitRequest represents a request to create a unit type CreateUnitRequest struct { - Name string `json:"name"` + Name string `json:"name"` } // CreateUnitResponse represents the response from creating a unit type CreateUnitResponse struct { - ID string `json:"id"` - Created time.Time `json:"created"` + ID string `json:"id"` + Created time.Time `json:"created"` } // ListUnitsResponse represents the response from listing units type ListUnitsResponse struct { - Units []*UnitMetadata `json:"units"` - Count int `json:"count"` + Units []*UnitMetadata `json:"units"` + Count int `json:"count"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` } // ListVersionsResponse represents the response from listing versions type ListVersionsResponse struct { - UnitID string `json:"unit_id"` - Versions []*Version `json:"versions"` - Count int `json:"count"` + UnitID string `json:"unit_id"` + Versions []*Version `json:"versions"` + Count int `json:"count"` } // RestoreVersionRequest represents a request to restore a version @@ -93,165 +97,179 @@ type RestoreVersionRequest struct { // RestoreVersionResponse represents the response from restoring a version type RestoreVersionResponse struct { - UnitID string `json:"unit_id"` - Timestamp time.Time `json:"restored_timestamp"` - Message string `json:"message"` + UnitID string `json:"unit_id"` + Timestamp time.Time `json:"restored_timestamp"` + Message string `json:"message"` } // UnitStatus represents the dependency status API response type UnitStatus struct { - UnitID string `json:"unit_id"` - Status string `json:"status"` - Incoming []IncomingEdge `json:"incoming"` - Summary Summary `json:"summary"` + UnitID string `json:"unit_id"` + Status string `json:"status"` + Incoming []IncomingEdge `json:"incoming"` + Summary Summary `json:"summary"` } type IncomingEdge struct { - EdgeID string `json:"edge_id,omitempty"` - FromUnitID string `json:"from_unit_id"` - FromOutput string `json:"from_output"` - Status string `json:"status"` - InDigest string `json:"in_digest,omitempty"` - OutDigest string `json:"out_digest,omitempty"` - LastInAt string `json:"last_in_at,omitempty"` - LastOutAt string `json:"last_out_at,omitempty"` + EdgeID string `json:"edge_id,omitempty"` + FromUnitID string `json:"from_unit_id"` + FromOutput string `json:"from_output"` + Status string `json:"status"` + InDigest string `json:"in_digest,omitempty"` + OutDigest string `json:"out_digest,omitempty"` + LastInAt string `json:"last_in_at,omitempty"` + LastOutAt string `json:"last_out_at,omitempty"` } type Summary struct { - IncomingOK int `json:"incoming_ok"` - IncomingPending int `json:"incoming_pending"` - IncomingUnknown int `json:"incoming_unknown"` + IncomingOK int `json:"incoming_ok"` + IncomingPending int `json:"incoming_pending"` + IncomingUnknown int `json:"incoming_unknown"` } // CreateUnit creates a new unit func (c *Client) CreateUnit(ctx context.Context, unitID string) (*CreateUnitResponse, error) { - req := CreateUnitRequest{Name: unitID} - - resp, err := c.doJSON(ctx, "POST", "/v1/units", req) - if err != nil { - return nil, err - } - defer resp.Body.Close() + req := CreateUnitRequest{Name: unitID} + + resp, err := c.doJSON(ctx, "POST", "/v1/units", req) + if err != nil { + return nil, err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return nil, parseError(resp) - } + if resp.StatusCode != http.StatusCreated { + return nil, parseError(resp) + } - var result CreateUnitResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } + var result CreateUnitResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } - return &result, nil + return &result, nil } -// ListUnits lists all units with optional prefix filter -func (c *Client) ListUnits(ctx context.Context, prefix string) (*ListUnitsResponse, error) { - path := "/v1/units" - if prefix != "" { - path += "?prefix=" + url.QueryEscape(prefix) - } +// ListUnits lists units with optional prefix and pagination. +func (c *Client) ListUnits(ctx context.Context, prefix string, page int, pageSize int) (*ListUnitsResponse, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } - resp, err := c.do(ctx, "GET", path, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() + params := url.Values{} + if prefix != "" { + params.Set("prefix", prefix) + } + params.Set("page", strconv.Itoa(page)) + params.Set("page_size", strconv.Itoa(pageSize)) - if resp.StatusCode != http.StatusOK { - return nil, parseError(resp) - } + path := "/v1/units" + if encoded := params.Encode(); encoded != "" { + path += "?" + encoded + } - var result ListUnitsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } + resp, err := c.do(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() - return &result, nil + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + + var result ListUnitsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil } // GetUnit gets unit metadata func (c *Client) GetUnit(ctx context.Context, unitID string) (*UnitMetadata, error) { - encodedID := encodeUnitID(unitID) - resp, err := c.do(ctx, "GET", "/v1/units/"+encodedID, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() + encodedID := encodeUnitID(unitID) + resp, err := c.do(ctx, "GET", "/v1/units/"+encodedID, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, parseError(resp) - } + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } - var result UnitMetadata - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } + var result UnitMetadata + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } - return &result, nil + return &result, nil } // DeleteUnit deletes a unit func (c *Client) DeleteUnit(ctx context.Context, unitID string) error { - encodedID := encodeUnitID(unitID) - resp, err := c.do(ctx, "DELETE", "/v1/units/"+encodedID, nil) - if err != nil { - return err - } - defer resp.Body.Close() + encodedID := encodeUnitID(unitID) + resp, err := c.do(ctx, "DELETE", "/v1/units/"+encodedID, nil) + if err != nil { + return err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return parseError(resp) - } + if resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } - return nil + return nil } // DownloadUnit downloads unit data func (c *Client) DownloadUnit(ctx context.Context, unitID string) ([]byte, error) { - encodedID := encodeUnitID(unitID) - resp, err := c.do(ctx, "GET", "/v1/units/"+encodedID+"/download", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() + encodedID := encodeUnitID(unitID) + resp, err := c.do(ctx, "GET", "/v1/units/"+encodedID+"/download", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, parseError(resp) - } + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } - return io.ReadAll(resp.Body) + return io.ReadAll(resp.Body) } // UploadUnit uploads unit data func (c *Client) UploadUnit(ctx context.Context, unitID string, data []byte, lockID string) error { - encodedID := encodeUnitID(unitID) - path := "/v1/units/" + encodedID + "/upload" - if lockID != "" { - path += "?if_locked_by=" + url.QueryEscape(lockID) - } + encodedID := encodeUnitID(unitID) + path := "/v1/units/" + encodedID + "/upload" + if lockID != "" { + path += "?if_locked_by=" + url.QueryEscape(lockID) + } - resp, err := c.do(ctx, "POST", path, bytes.NewReader(data)) - if err != nil { - return err - } - defer resp.Body.Close() + resp, err := c.do(ctx, "POST", path, bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return parseError(resp) - } + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } - return nil + return nil } // LockUnit locks a unit func (c *Client) LockUnit(ctx context.Context, unitID string, lockInfo *LockInfo) (*LockInfo, error) { - encodedID := encodeUnitID(unitID) - resp, err := c.doJSON(ctx, "POST", "/v1/units/"+encodedID+"/lock", lockInfo) - if err != nil { - return nil, err - } - defer resp.Body.Close() + encodedID := encodeUnitID(unitID) + resp, err := c.doJSON(ctx, "POST", "/v1/units/"+encodedID+"/lock", lockInfo) + if err != nil { + return nil, err + } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, parseError(resp) @@ -267,14 +285,14 @@ func (c *Client) LockUnit(ctx context.Context, unitID string, lockInfo *LockInfo // UnlockUnit unlocks a unit func (c *Client) UnlockUnit(ctx context.Context, unitID string, lockID string) error { - req := map[string]string{"id": lockID} - encodedID := encodeUnitID(unitID) - - resp, err := c.doJSON(ctx, "DELETE", "/v1/units/"+encodedID+"/unlock", req) - if err != nil { - return err - } - defer resp.Body.Close() + req := map[string]string{"id": lockID} + encodedID := encodeUnitID(unitID) + + resp, err := c.doJSON(ctx, "DELETE", "/v1/units/"+encodedID+"/unlock", req) + if err != nil { + return err + } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return parseError(resp) @@ -286,20 +304,20 @@ func (c *Client) UnlockUnit(ctx context.Context, unitID string, lockID string) e // Helper methods func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } - if c.authToken != "" { - req.Header.Set("Authorization", "Bearer "+c.authToken) - } + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } - return c.httpClient.Do(req) + return c.httpClient.Do(req) } func (c *Client) doJSON(ctx context.Context, method, path string, payload interface{}) (*http.Response, error) { @@ -317,9 +335,9 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload interf // ListUnitVersions lists all versions for a unit func (c *Client) ListUnitVersions(ctx context.Context, unitID string) ([]*Version, error) { - encodedID := encodeUnitID(unitID) - path := fmt.Sprintf("/v1/units/%s/versions", encodedID) - + encodedID := encodeUnitID(unitID) + path := fmt.Sprintf("/v1/units/%s/versions", encodedID) + resp, err := c.do(ctx, "GET", path, nil) if err != nil { return nil, err @@ -340,14 +358,14 @@ func (c *Client) ListUnitVersions(ctx context.Context, unitID string) ([]*Versio // RestoreUnitVersion restores a unit to a specific version func (c *Client) RestoreUnitVersion(ctx context.Context, unitID string, versionTimestamp time.Time, lockID string) error { - encodedID := encodeUnitID(unitID) - path := fmt.Sprintf("/v1/units/%s/restore", encodedID) - + encodedID := encodeUnitID(unitID) + path := fmt.Sprintf("/v1/units/%s/restore", encodedID) + req := RestoreVersionRequest{ Timestamp: versionTimestamp, LockID: lockID, } - + resp, err := c.doJSON(ctx, "POST", path, req) if err != nil { return err @@ -363,21 +381,21 @@ func (c *Client) RestoreUnitVersion(ctx context.Context, unitID string, versionT // GetUnitStatus fetches dependency status for a unit func (c *Client) GetUnitStatus(ctx context.Context, unitID string) (*UnitStatus, error) { - encodedID := encodeUnitID(unitID) - path := "/v1/units/" + encodedID + "/status" - resp, err := c.do(ctx, "GET", path, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, parseError(resp) - } - var st UnitStatus - if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - return &st, nil + encodedID := encodeUnitID(unitID) + path := "/v1/units/" + encodedID + "/status" + resp, err := c.do(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + var st UnitStatus + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &st, nil } func parseError(resp *http.Response) error { @@ -395,7 +413,7 @@ func parseError(resp *http.Response) error { // encodeUnitID encodes a unit ID for use in URLs by replacing slashes with double underscores func encodeUnitID(id string) string { - return strings.ReplaceAll(id, "/", "__") + return strings.ReplaceAll(id, "/", "__") } // SetBearerToken sets the Authorization bearer token for subsequent requests. diff --git a/taco/pkg/sdk/client_test.go b/taco/pkg/sdk/client_test.go index e56f244f2..8424ec7a2 100644 --- a/taco/pkg/sdk/client_test.go +++ b/taco/pkg/sdk/client_test.go @@ -12,17 +12,17 @@ import ( func TestClient_CreateUnit(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/units" || r.Method != "POST" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } + if r.URL.Path != "/v1/units" || r.Method != "POST" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } - var req CreateUnitRequest - json.NewDecoder(r.Body).Decode(&req) + var req CreateUnitRequest + json.NewDecoder(r.Body).Decode(&req) - resp := CreateUnitResponse{ - ID: req.ID, - Created: time.Now(), - } + resp := CreateUnitResponse{ + ID: req.Name, + Created: time.Now(), + } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) @@ -31,40 +31,46 @@ func TestClient_CreateUnit(t *testing.T) { // Test client client := NewClient(server.URL) - resp, err := client.CreateUnit(context.Background(), "test/unit") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if resp.ID != "test/unit" { - t.Errorf("expected ID 'test/unit', got %s", resp.ID) - } + resp, err := client.CreateUnit(context.Background(), "test/unit") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.ID != "test/unit" { + t.Errorf("expected ID 'test/unit', got %s", resp.ID) + } } func TestClient_ListUnits(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/units" || r.Method != "GET" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - - resp := ListUnitsResponse{ - Units: []*UnitMetadata{ - { - ID: "unit1", - Size: 100, - Updated: time.Now(), - Locked: false, - }, - { - ID: "unit2", - Size: 200, - Updated: time.Now(), - Locked: true, - }, - }, - Count: 2, - } + if r.URL.Path != "/v1/units" || r.Method != "GET" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("page_size") != "25" { + t.Errorf("unexpected pagination params: %v", r.URL.Query()) + } + + resp := ListUnitsResponse{ + Units: []*UnitMetadata{ + { + ID: "unit1", + Size: 100, + Updated: time.Now(), + Locked: false, + }, + { + ID: "unit2", + Size: 200, + Updated: time.Now(), + Locked: true, + }, + }, + Count: 2, + Total: 5, + Page: 2, + PageSize: 25, + } json.NewEncoder(w).Encode(resp) })) @@ -72,36 +78,40 @@ func TestClient_ListUnits(t *testing.T) { // Test client client := NewClient(server.URL) - resp, err := client.ListUnits(context.Background(), "") + resp, err := client.ListUnits(context.Background(), "", 2, 25) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp.Count != 2 { - t.Errorf("expected 2 units, got %d", resp.Count) - } + if resp.Count != 2 { + t.Errorf("expected 2 units, got %d", resp.Count) + } + + if len(resp.Units) != 2 { + t.Errorf("expected 2 units in array, got %d", len(resp.Units)) + } - if len(resp.Units) != 2 { - t.Errorf("expected 2 units in array, got %d", len(resp.Units)) - } + if resp.Total != 5 || resp.Page != 2 || resp.PageSize != 25 { + t.Errorf("unexpected pagination metadata: %+v", resp) + } } func TestClient_ErrorHandling(t *testing.T) { // Mock server that returns errors server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]string{"error": "Unit already exists"}) + json.NewEncoder(w).Encode(map[string]string{"error": "Unit already exists"}) })) defer server.Close() // Test client client := NewClient(server.URL) - _, err := client.CreateUnit(context.Background(), "test/unit") + _, err := client.CreateUnit(context.Background(), "test/unit") if err == nil { t.Fatal("expected error, got nil") } - if err.Error() != "HTTP 409: Unit already exists" { - t.Errorf("unexpected error message: %v", err) - } + if err.Error() != "HTTP 409: Unit already exists" { + t.Errorf("unexpected error message: %v", err) + } } diff --git a/ui/src/api/statesman_serverFunctions.ts b/ui/src/api/statesman_serverFunctions.ts index 49642c339..fd8bbb652 100644 --- a/ui/src/api/statesman_serverFunctions.ts +++ b/ui/src/api/statesman_serverFunctions.ts @@ -2,9 +2,15 @@ import { createServerFn } from "@tanstack/react-start" import { createUnit, getUnit, listUnits, getUnitVersions, unlockUnit, lockUnit, getUnitStatus, deleteUnit, downloadLatestState, forcePushState, restoreUnitStateVersion } from "./statesman_units" export const listUnitsFn = createServerFn({method: 'GET'}) - .inputValidator((data : {userId: string, organisationId: string, email: string}) => data) + .inputValidator((data : {userId: string, organisationId: string, email: string, page?: number, pageSize?: number}) => data) .handler(async ({ data }) => { - const units : any = await listUnits(data.organisationId, data.userId, data.email); + const units : any = await listUnits( + data.organisationId, + data.userId, + data.email, + data.page ?? 1, + data.pageSize ?? 20, + ); return units; }) @@ -124,4 +130,4 @@ export const deleteUnitFn = createServerFn({method: 'POST'}) .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) .handler(async ({ data }) => { await deleteUnit(data.organisationId, data.userId, data.email, data.unitId) -}) \ No newline at end of file +}) diff --git a/ui/src/api/statesman_units.ts b/ui/src/api/statesman_units.ts index dc03a7eda..45e67bf49 100644 --- a/ui/src/api/statesman_units.ts +++ b/ui/src/api/statesman_units.ts @@ -3,8 +3,12 @@ function generateRequestId(): string { return `ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } -export async function listUnits(orgId: string, userId: string, email: string) { - const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`, { +export async function listUnits(orgId: string, userId: string, email: string, page = 1, pageSize = 20) { + const url = new URL(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`); + url.searchParams.set('page', String(page)); + url.searchParams.set('page_size', String(pageSize)); + + const response = await fetch(url.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -261,4 +265,4 @@ export async function deleteUnit(orgId: string, userId: string, email: string, u throw new Error(`Failed to delete unit: ${response.statusText}`); } -} \ No newline at end of file +} diff --git a/ui/src/api/tokens.ts b/ui/src/api/tokens.ts index a0499d13e..894b197a6 100644 --- a/ui/src/api/tokens.ts +++ b/ui/src/api/tokens.ts @@ -3,8 +3,13 @@ function generateRequestId(): string { return `ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } -export const getTokens = async (organizationId: string, userId: string) => { - const query = new URLSearchParams({ org_id: organizationId, user_id: userId }); +export const getTokens = async (organizationId: string, userId: string, page = 1, pageSize = 20) => { + const query = new URLSearchParams({ + org_id: organizationId, + user_id: userId, + page: page.toString(), + page_size: pageSize.toString(), + }); const url = `${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens?${query.toString()}`; const response = await fetch(url, { method: 'GET', diff --git a/ui/src/api/tokens_serverFunctions.ts b/ui/src/api/tokens_serverFunctions.ts index 8c19db1b7..5b54e05bc 100644 --- a/ui/src/api/tokens_serverFunctions.ts +++ b/ui/src/api/tokens_serverFunctions.ts @@ -4,9 +4,9 @@ import { verifyToken } from "./tokens"; import { deleteToken } from "./tokens"; export const getTokensFn = createServerFn({method: 'GET'}) - .inputValidator((data: {organizationId: string, userId: string}) => data) - .handler(async ({data: {organizationId, userId}}) => { - return getTokens(organizationId, userId); + .inputValidator((data: {organizationId: string, userId: string, page?: number, pageSize?: number}) => data) + .handler(async ({data: {organizationId, userId, page = 1, pageSize = 20}}) => { + return getTokens(organizationId, userId, page, pageSize); }) export const createTokenFn = createServerFn({method: 'POST'}) @@ -25,4 +25,4 @@ export const deleteTokenFn = createServerFn({method: 'POST'}) .inputValidator((data: {organizationId: string, userId: string, tokenId: string}) => data) .handler(async ({data: {organizationId, userId, tokenId}}) => { return deleteToken(organizationId, userId, tokenId); -}) \ No newline at end of file +}) diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx index 869876550..ee4855156 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx @@ -16,7 +16,7 @@ export const Route = createFileRoute( component: RouteComponent, loader: async ({ context }) => { const { user, organisationId } = context; - const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) + const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || '', page: 1, pageSize: 20}}) return { tokens, user, organisationId } }, // Disable caching for token data - always fetch fresh @@ -26,13 +26,29 @@ export const Route = createFileRoute( function RouteComponent() { const { tokens, user, organisationId } = Route.useLoaderData() - const [tokenList, setTokenList] = useState(tokens) + const [tokenPage, setTokenPage] = useState(tokens) + const [currentPage, setCurrentPage] = useState(tokens?.page ?? 1) + const [pageSize] = useState(tokens?.page_size ?? 20) const [newToken, setNewToken] = useState('') const [open, setOpen] = useState(false) const [nickname, setNickname] = useState('') const [expiry, setExpiry] = useState<'1_week' | '30_days' | 'no_expiry'>('1_week') const [submitting, setSubmitting] = useState(false) const { toast } = useToast() + const tokenList = tokenPage?.tokens ?? [] + const totalTokens = tokenPage?.total ?? tokenList.length + const effectivePage = tokenPage?.page ?? currentPage + const effectivePageSize = tokenPage?.page_size ?? pageSize + const canGoNext = effectivePage * effectivePageSize < totalTokens + const canGoPrev = effectivePage > 1 + + const refreshTokens = async (page: number = currentPage) => { + const next = await getTokensFn({ + data: { organizationId: organisationId, userId: user?.id || '', page, pageSize }, + }) + setTokenPage(next) + setCurrentPage(next?.page ?? page) + } const computeExpiry = (value: '1_week' | '30_days' | 'no_expiry'): string | null => { console.log('value', value) if (value === 'no_expiry') return null @@ -74,8 +90,7 @@ function RouteComponent() { setOpen(false) setNickname('') setExpiry('no_expiry') - const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) - setTokenList(newTokenList) + await refreshTokens(1) } finally { setSubmitting(false) } @@ -95,8 +110,7 @@ function RouteComponent() { }) }).finally(async () => { setSubmitting(false) - const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) - setTokenList(newTokenList) + await refreshTokens(currentPage) }) } return ( @@ -160,40 +174,55 @@ function RouteComponent() { {tokenList.length === 0 ? (

No tokens generated yet

) : ( - - - - Name - Token - Expires - Created - Actions - - - - {tokenList.map((token, index) => ( - - {token.name} - •••••••••••{token.token.slice(-4)} - - {isTokenExpired(token) - ? This token has expired - : (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')} - - {formatDateString(token.created_at)} - - - + <> +
+ + + Name + Token + Expires + Created + Actions - ))} - -
+ + + {tokenList.map((token, index) => ( + + {token.name} + •••••••••••{token.token.slice(-4)} + + {isTokenExpired(token) + ? This token has expired + : (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')} + + {formatDateString(token.created_at)} + + + + + ))} + + +
+

+ Showing {tokenList.length} of {totalTokens} tokens (page {effectivePage}) +

+
+ + +
+
+ )} diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx index ff2d47915..7bbc47b12 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx @@ -34,12 +34,14 @@ export const Route = createFileRoute( pendingComponent: PageLoading, loader: async ({ context }) => { const { user, organisationId } = context; - + const pageSize = 20; const unitsData = await listUnitsFn({ data: { organisationId: organisationId || '', userId: user?.id || '', - email: user?.email || '' + email: user?.email || '', + page: 1, + pageSize, } }); @@ -170,32 +172,55 @@ function CreateUnitModal({ onUnitCreated, onUnitOptimistic, onUnitFailed }: { function RouteComponent() { const { unitsData, organisationId, user } = Route.useLoaderData() - const [units, setUnits] = useState(unitsData?.units || []) + const [pageData, setPageData] = useState(unitsData) + const [currentPage, setCurrentPage] = useState(unitsData?.page || 1) + const pageSize = (pageData as any)?.page_size || 20 + const total = (pageData as any)?.total || (pageData as any)?.units?.length || 0 + const units = (pageData?.units || []).slice().sort((a: any, b: any) => a.name.localeCompare(b.name)) const navigate = Route.useNavigate() const router = useRouter() + + async function loadPage(page: number) { + const next = await listUnitsFn({ + data: { + organisationId: organisationId || '', + userId: user?.id || '', + email: user?.email || '', + page, + pageSize, + }, + }) + setPageData(next) + setCurrentPage(next?.page || page) + } // Handle optimistic update - add immediately function handleUnitOptimistic(tempUnit: any) { - setUnits(prev => [{ - ...tempUnit, - locked: false, - size: 0, - updated: new Date(), - isOptimistic: true - }, ...prev]) + setPageData(prev => { + const nextUnits = [{ + ...tempUnit, + locked: false, + size: 0, + updated: new Date(), + isOptimistic: true + }, ...(prev?.units || [])] + return { ...prev, units: nextUnits } + }) } // Handle actual creation - refresh from server async function handleUnitCreated() { - const unitsData = await listUnitsFn({data: {organisationId: organisationId, userId: user?.id || '', email: user?.email || ''}}) - setUnits(unitsData.units) + await loadPage(1) } // Handle failure - remove optimistic unit function handleUnitFailed() { - setUnits(prev => prev.filter((u: any) => !u.isOptimistic)) + setPageData(prev => ({ ...prev, units: (prev?.units || []).filter((u: any) => !u.isOptimistic) })) } + const canGoPrev = currentPage > 1 + const canGoNext = currentPage * pageSize < total + return (<>
@@ -280,6 +305,15 @@ function RouteComponent() { )} +
+

+ Showing {units.length} of {total} units (page {currentPage}, {pageSize} per page) +

+
+ + +
+
From 0b36d0610ee0a2b20e062ac28386d6d30f3ec400 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Fri, 21 Nov 2025 17:32:24 -0800 Subject: [PATCH 2/2] remove cli changes since we can't test --- taco/cmd/taco/commands/rbac.go | 89 +++++----------------------------- taco/cmd/taco/commands/unit.go | 79 +++++------------------------- taco/pkg/sdk/client.go | 30 +++--------- taco/pkg/sdk/client_test.go | 14 +----- 4 files changed, 33 insertions(+), 179 deletions(-) diff --git a/taco/cmd/taco/commands/rbac.go b/taco/cmd/taco/commands/rbac.go index cef9801cf..1ada259ad 100644 --- a/taco/cmd/taco/commands/rbac.go +++ b/taco/cmd/taco/commands/rbac.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "os" - "strconv" "strings" "text/tabwriter" @@ -49,29 +48,6 @@ type PermissionRule struct { Effect string `json:"effect"` } -type paginatedRolesResponse struct { - Roles []Role `json:"roles"` - Count int `json:"count"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` -} - -type paginatedPermissionsResponse struct { - Permissions []Permission `json:"permissions"` - Count int `json:"count"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` -} - -var ( - rbacRoleListPage = 1 - rbacRoleListPageSize = 50 - rbacPermissionListPage = 1 - rbacPermissionListSize = 50 -) - // rbacCmd represents the rbac command var rbacCmd = &cobra.Command{ Use: "rbac", @@ -332,8 +308,6 @@ func init() { rbacRoleCmd.AddCommand(rbacRoleDeleteCmd) rbacRoleCmd.AddCommand(rbacRoleAssignPolicyCmd) rbacRoleCmd.AddCommand(rbacRoleRevokePermissionCmd) - rbacRoleListCmd.Flags().IntVar(&rbacRoleListPage, "page", 1, "Page number for roles") - rbacRoleListCmd.Flags().IntVar(&rbacRoleListPageSize, "page-size", 50, "Roles per page") } // rbac role create command @@ -379,26 +353,9 @@ var rbacRoleListCmd = &cobra.Command{ Long: `List all roles in the system.`, RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - page := rbacRoleListPage - if page < 1 { - page = 1 - } - pageSize := rbacRoleListPageSize - if pageSize < 1 { - pageSize = 50 - } - - query := url.Values{} - query.Set("page", strconv.Itoa(page)) - query.Set("page_size", strconv.Itoa(pageSize)) + printVerbose("Listing all roles") - path := "/v1/rbac/roles" - if encoded := query.Encode(); encoded != "" { - path += "?" + encoded - } - printVerbose("Listing roles (page %d, size %d)", page, pageSize) - - resp, err := client.Get(context.Background(), path) + resp, err := client.Get(context.Background(), "/v1/rbac/roles") if err != nil { return fmt.Errorf("failed to list roles: %w", err) } @@ -412,12 +369,12 @@ var rbacRoleListCmd = &cobra.Command{ return fmt.Errorf("failed to read response: %w", err) } - var rolesPage paginatedRolesResponse - if err := json.Unmarshal(body, &rolesPage); err != nil { + var roles []Role + if err := json.Unmarshal(body, &roles); err != nil { return fmt.Errorf("failed to parse response: %w", err) } - if len(rolesPage.Roles) == 0 { + if len(roles) == 0 { fmt.Println("No roles found") return nil } @@ -426,7 +383,7 @@ var rbacRoleListCmd = &cobra.Command{ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tDESCRIPTION\tPERMISSIONS\tCREATED") - for _, role := range rolesPage.Roles { + for _, role := range roles { permissions := strings.Join(role.Permissions, ", ") name := role.Name if name == "" { @@ -441,7 +398,7 @@ var rbacRoleListCmd = &cobra.Command{ } w.Flush() - fmt.Printf("\nPage %d (size %d) — showing %d of %d roles\n", rolesPage.Page, rolesPage.PageSize, len(rolesPage.Roles), rolesPage.Total) + fmt.Printf("\nTotal: %d roles\n", len(roles)) return nil }, @@ -550,8 +507,6 @@ func init() { rbacPermissionCmd.AddCommand(rbacPermissionCreateCmd) rbacPermissionCmd.AddCommand(rbacPermissionListCmd) rbacPermissionCmd.AddCommand(rbacPermissionDeleteCmd) - rbacPermissionListCmd.Flags().IntVar(&rbacPermissionListPage, "page", 1, "Page number for permissions") - rbacPermissionListCmd.Flags().IntVar(&rbacPermissionListSize, "page-size", 50, "Permissions per page") } // rbac permission create command @@ -618,25 +573,7 @@ var rbacPermissionListCmd = &cobra.Command{ Short: "List all permissions", RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - page := rbacPermissionListPage - if page < 1 { - page = 1 - } - pageSize := rbacPermissionListSize - if pageSize < 1 { - pageSize = 50 - } - - query := url.Values{} - query.Set("page", strconv.Itoa(page)) - query.Set("page_size", strconv.Itoa(pageSize)) - - path := "/v1/rbac/permissions" - if encoded := query.Encode(); encoded != "" { - path += "?" + encoded - } - - resp, err := client.Get(context.Background(), path) + resp, err := client.Get(context.Background(), "/v1/rbac/permissions") if err != nil { return fmt.Errorf("failed to list permissions: %w", err) } @@ -650,12 +587,12 @@ var rbacPermissionListCmd = &cobra.Command{ return fmt.Errorf("failed to read response: %w", err) } - var permissionsPage paginatedPermissionsResponse - if err := json.Unmarshal(body, &permissionsPage); err != nil { + var permissions []Permission + if err := json.Unmarshal(body, &permissions); err != nil { return fmt.Errorf("failed to parse response: %w", err) } - if len(permissionsPage.Permissions) == 0 { + if len(permissions) == 0 { fmt.Println("No permissions found") return nil } @@ -663,7 +600,7 @@ var rbacPermissionListCmd = &cobra.Command{ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tDESCRIPTION\tRULES\tCREATED") - for _, permission := range permissionsPage.Permissions { + for _, permission := range permissions { rules := "" for i, rule := range permission.Rules { if i > 0 { @@ -685,7 +622,7 @@ var rbacPermissionListCmd = &cobra.Command{ } w.Flush() - fmt.Printf("\nPage %d (size %d) — showing %d of %d permissions\n", permissionsPage.Page, permissionsPage.PageSize, len(permissionsPage.Permissions), permissionsPage.Total) + fmt.Printf("\nTotal: %d permissions\n", len(permissions)) return nil }, } diff --git a/taco/cmd/taco/commands/unit.go b/taco/cmd/taco/commands/unit.go index fdfce4e0c..1733066e1 100644 --- a/taco/cmd/taco/commands/unit.go +++ b/taco/cmd/taco/commands/unit.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "regexp" - "strconv" "strings" "text/tabwriter" "time" @@ -44,9 +43,6 @@ func init() { unitCmd.AddCommand(unitVersionsCmd) unitCmd.AddCommand(unitRestoreCmd) unitCmd.AddCommand(unitStatusCmd) - unitListCmd.Flags().IntVar(&unitListPage, "page", 1, "Page number for unit list") - unitListCmd.Flags().IntVar(&unitListPageSize, "page-size", 50, "Units per page") - unitListCmd.Flags().BoolVar(&unitListAll, "all", false, "Fetch all pages") } var unitCreateCmd = &cobra.Command{ @@ -78,15 +74,6 @@ var ( unitStatusOutput string ) -var ( - unitListPage = 1 - unitListPageSize = 50 - unitListAll bool - unitStatusPage = 1 - unitStatusPageSize = 50 - unitStatusAll = true -) - var unitStatusCmd = &cobra.Command{ Use: "status [unit-id]", Short: "Show dependency status for a unit or prefix", @@ -103,29 +90,12 @@ var unitStatusCmd = &cobra.Command{ if len(args) == 1 { units = []string{args[0]} } else { - ctx := context.Background() - page := unitStatusPage - if page < 1 { - page = 1 - } - size := unitStatusPageSize - if size < 1 { - size = 50 + resp, err := client.ListUnits(context.Background(), pfx) + if err != nil { + return fmt.Errorf("failed to list units: %w", err) } - - currentPage := page - for { - resp, err := client.ListUnits(ctx, pfx, currentPage, size) - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - for _, u := range resp.Units { - units = append(units, u.ID) - } - if !unitStatusAll || len(resp.Units) == 0 || int64(currentPage*size) >= resp.Total { - break - } - currentPage++ + for _, u := range resp.Units { + units = append(units, u.ID) } } @@ -172,9 +142,6 @@ var unitStatusCmd = &cobra.Command{ func init() { unitStatusCmd.Flags().StringVar(&unitStatusPrefix, "prefix", "", "Prefix to filter units") unitStatusCmd.Flags().StringVarP(&unitStatusOutput, "output", "o", "table", "Output format: table|json") - unitStatusCmd.Flags().IntVar(&unitStatusPage, "page", 1, "Page number when listing units for status (ignored when --all is set)") - unitStatusCmd.Flags().IntVar(&unitStatusPageSize, "page-size", 50, "Units per page when listing units for status") - unitStatusCmd.Flags().BoolVar(&unitStatusAll, "all", true, "Fetch all pages when no unit-id is provided") } var unitListCmd = &cobra.Command{ @@ -189,44 +156,22 @@ var unitListCmd = &cobra.Command{ } printVerbose("Listing units with prefix: %s", prefix) - page := unitListPage - if page < 1 { - page = 1 - } - pageSize := unitListPageSize - if pageSize < 1 { - pageSize = 50 - } - ctx := context.Background() - currentPage := page - var combinedUnits []*sdk.UnitMetadata - var total int64 - - for { - respPage, err := client.ListUnits(ctx, prefix, currentPage, pageSize) - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - combinedUnits = append(combinedUnits, respPage.Units...) - total = respPage.Total - - if !unitListAll || len(respPage.Units) == 0 || int64(currentPage*pageSize) >= total { - break - } - currentPage++ + resp, err := client.ListUnits(ctx, prefix) + if err != nil { + return fmt.Errorf("failed to list units: %w", err) } - if len(combinedUnits) == 0 { + if len(resp.Units) == 0 { fmt.Println("No units found") return nil } // Filter by RBAC if enabled - filtered := combinedUnits + filtered := resp.Units var err error if rbacEnabled { - filtered, err = filterUnitsByRBAC(ctx, client, combinedUnits) + filtered, err = filterUnitsByRBAC(ctx, client, resp.Units) if err != nil { printVerbose("Warning: failed to filter units by RBAC: %v", err) } @@ -247,7 +192,7 @@ var unitListCmd = &cobra.Command{ fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", u.ID, u.Size, u.Updated.Format("2006-01-02 15:04:05"), locked) } w.Flush() - fmt.Printf("\nServer total: %d units | showing %d (RBAC visible: %d) [page %d size %d]\n", total, len(combinedUnits), len(filtered), page, pageSize) + fmt.Printf("\nTotal: %d units (showing %d with read access)\n", resp.Count, len(filtered)) return nil }, } diff --git a/taco/pkg/sdk/client.go b/taco/pkg/sdk/client.go index a46ee2f56..b777e0da3 100644 --- a/taco/pkg/sdk/client.go +++ b/taco/pkg/sdk/client.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" ) @@ -75,11 +74,8 @@ type CreateUnitResponse struct { // ListUnitsResponse represents the response from listing units type ListUnitsResponse struct { - Units []*UnitMetadata `json:"units"` - Count int `json:"count"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` + Units []*UnitMetadata `json:"units"` + Count int `json:"count"` } // ListVersionsResponse represents the response from listing versions @@ -149,25 +145,11 @@ func (c *Client) CreateUnit(ctx context.Context, unitID string) (*CreateUnitResp return &result, nil } -// ListUnits lists units with optional prefix and pagination. -func (c *Client) ListUnits(ctx context.Context, prefix string, page int, pageSize int) (*ListUnitsResponse, error) { - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 50 - } - - params := url.Values{} - if prefix != "" { - params.Set("prefix", prefix) - } - params.Set("page", strconv.Itoa(page)) - params.Set("page_size", strconv.Itoa(pageSize)) - +// ListUnits lists all units with optional prefix filter +func (c *Client) ListUnits(ctx context.Context, prefix string) (*ListUnitsResponse, error) { path := "/v1/units" - if encoded := params.Encode(); encoded != "" { - path += "?" + encoded + if prefix != "" { + path += "?prefix=" + url.QueryEscape(prefix) } resp, err := c.do(ctx, "GET", path, nil) diff --git a/taco/pkg/sdk/client_test.go b/taco/pkg/sdk/client_test.go index 8424ec7a2..521df7988 100644 --- a/taco/pkg/sdk/client_test.go +++ b/taco/pkg/sdk/client_test.go @@ -47,9 +47,6 @@ func TestClient_ListUnits(t *testing.T) { if r.URL.Path != "/v1/units" || r.Method != "GET" { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } - if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("page_size") != "25" { - t.Errorf("unexpected pagination params: %v", r.URL.Query()) - } resp := ListUnitsResponse{ Units: []*UnitMetadata{ @@ -66,10 +63,7 @@ func TestClient_ListUnits(t *testing.T) { Locked: true, }, }, - Count: 2, - Total: 5, - Page: 2, - PageSize: 25, + Count: 2, } json.NewEncoder(w).Encode(resp) @@ -78,7 +72,7 @@ func TestClient_ListUnits(t *testing.T) { // Test client client := NewClient(server.URL) - resp, err := client.ListUnits(context.Background(), "", 2, 25) + resp, err := client.ListUnits(context.Background(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,10 +84,6 @@ func TestClient_ListUnits(t *testing.T) { if len(resp.Units) != 2 { t.Errorf("expected 2 units in array, got %d", len(resp.Units)) } - - if resp.Total != 5 || resp.Page != 2 || resp.PageSize != 25 { - t.Errorf("unexpected pagination metadata: %+v", resp) - } } func TestClient_ErrorHandling(t *testing.T) {