Files
query-orchestration/cmd/auth_related/user.creation.tool/permit.go
T
Jay Brown 6dccf494f8 Merged in feature/permit-policy-cleanup (pull request #210)
cleanup permit policies and correct documentation

* docs

* policy cleanup

* refactor

* Merge remote-tracking branch 'origin/feature/permit-policy-cleanup' into feature/permit-policy-cleanup

* docs and edits
2026-02-19 20:22:59 +00:00

679 lines
22 KiB
Go

// permit.go handles Permit.io API operations for user management and role-based access control (RBAC).
// This file provides functions to create, delete, search, and manage users in Permit.io, as well as
// assign/unassign roles and validate role definitions. It integrates with Cognito user data to sync
// user information between the two systems.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Permit.io types
type PermitUser struct {
Key string `json:"key"`
Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Attributes map[string]interface{} `json:"attributes,omitempty"`
}
type RoleAssignment struct {
User string `json:"user"`
Role string `json:"role"`
Tenant string `json:"tenant"`
}
// APIKeyScope represents the scope information returned by the Permit.io API key introspection endpoint.
// It contains the organization, project, and environment IDs that the API key has access to.
// Organization-level keys will have only OrganizationID set.
// Project-level keys will have OrganizationID and ProjectID set.
// Environment-level keys will have all three fields set.
type APIKeyScope struct {
OrganizationID string `json:"organization_id"`
ProjectID string `json:"project_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
}
// getAPIKeyScope retrieves the scope information for the provided Permit.io API key.
// This allows the tool to introspect a project-level or environment-level API key
// to automatically determine the project and environment IDs without requiring
// the user to specify them explicitly.
//
// Parameters:
// - client: HTTP client for making API requests
// - config: Application configuration containing the Permit.io API key and base URL
//
// Returns:
// - *APIKeyScope: The scope information containing organization, project, and environment IDs
// - error: Error if the API call fails or response cannot be parsed
func getAPIKeyScope(client *http.Client, config AppConfig) (*APIKeyScope, error) {
url := fmt.Sprintf("%s/v2/api-key/scope", config.PermitBaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var scope APIKeyScope
if err := json.NewDecoder(resp.Body).Decode(&scope); err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return &scope, nil
}
func createPermitUser(client *http.Client, config AppConfig, cognitoUser *CognitoUserResponse) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/users", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID)
userObj := PermitUser{
Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key
Email: cognitoUser.Email,
FirstName: cognitoUser.FirstName,
LastName: cognitoUser.LastName,
Attributes: map[string]interface{}{
"cognito_username": cognitoUser.Username,
"cognito_status": cognitoUser.Status,
},
}
jsonData, err := json.Marshal(userObj)
if err != nil {
return fmt.Errorf("error marshaling user data: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// If we get a 404, provide helpful debugging info
if resp.StatusCode == http.StatusNotFound {
var errResp map[string]interface{}
if err := json.Unmarshal(body, &errResp); err == nil {
return fmt.Errorf("API error (status 404): %s\nTry running with '-project list' to see available projects and environments", errResp["message"])
}
}
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
// User already exists or was created successfully
return nil
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Error.Message)
}
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func assignRole(client *http.Client, config AppConfig, userID, role string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID)
assignment := RoleAssignment{
User: userID,
Role: role,
Tenant: "default", // Using default tenant
}
jsonData, err := json.Marshal(assignment)
if err != nil {
return fmt.Errorf("error marshaling role assignment: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
// Role already assigned or was assigned successfully
return nil
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Error.Message)
}
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func findPermitUserByEmail(client *http.Client, config AppConfig, email string) (string, error) {
// Search for user by email
url := fmt.Sprintf("%s/v2/facts/%s/%s/users?search=%s", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID, email)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response to find user
// First try to unmarshal as an object (paginated response)
var response map[string]interface{}
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("error parsing response: %w", err)
}
// Look for users in common pagination structures
var users []interface{}
if data, ok := response["data"].([]interface{}); ok {
users = data
} else if items, ok := response["items"].([]interface{}); ok {
users = items
} else if usersList, ok := response["users"].([]interface{}); ok {
users = usersList
} else if _, ok := response["key"]; ok {
// Single user object returned
users = []interface{}{response}
} else {
// Try direct array unmarshaling as fallback
var directUsers []interface{}
if err := json.Unmarshal(body, &directUsers); err == nil {
users = directUsers
} else {
// If all else fails, assume the response itself is an array
return "", fmt.Errorf("unexpected response format")
}
}
// Find user with matching email
for _, u := range users {
if user, ok := u.(map[string]interface{}); ok {
if userEmail, ok := user["email"].(string); ok && userEmail == email {
if key, ok := user["key"].(string); ok {
return key, nil
}
}
}
}
return "", nil // User not found
}
func deletePermitUser(client *http.Client, config AppConfig, userKey string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/users/%s", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID, userKey)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Error.Message)
}
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// getPermitUserRoles retrieves the roles assigned to a user in Permit.io
func getPermitUserRoles(client *http.Client, config AppConfig, userKey string) ([]string, error) {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments?user=%s",
config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID, userKey)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
var roles []string
var assignments []interface{}
// First try to unmarshal as direct array (common case)
var directAssignments []interface{}
if err := json.Unmarshal(body, &directAssignments); err == nil {
assignments = directAssignments
} else {
// If that fails, try as object with data/items/etc
var response map[string]interface{}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("error parsing response as object or array: %w", err)
}
// Look for role assignments in common response structures
if data, ok := response["data"].([]interface{}); ok {
assignments = data
} else if items, ok := response["items"].([]interface{}); ok {
assignments = items
} else if results, ok := response["results"].([]interface{}); ok {
assignments = results
}
}
// Extract role names from assignments
for _, assignment := range assignments {
if roleAssignment, ok := assignment.(map[string]interface{}); ok {
if role, ok := roleAssignment["role"].(string); ok {
roles = append(roles, role)
}
}
}
return roles, nil
}
// unassignRole removes a role assignment from a user in Permit.io
func unassignRole(client *http.Client, config AppConfig, userID, role string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID)
assignment := RoleAssignment{
User: userID,
Role: role,
Tenant: "default", // Using default tenant
}
jsonData, err := json.Marshal(assignment)
if err != nil {
return fmt.Errorf("error marshaling role assignment: %w", err)
}
req, err := http.NewRequest("DELETE", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
// Role was unassigned, already unassigned, or didn't exist
return nil
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Error.Message)
}
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// userExistsInPermit checks if a user exists in Permit.io by email
func userExistsInPermit(client *http.Client, config AppConfig, email string) (bool, string, error) {
userKey, err := findPermitUserByEmail(client, config, email)
if err != nil {
return false, "", err
}
return userKey != "", userKey, nil
}
func listProjectsAndEnvironments(config AppConfig) {
if config.PermitAPIKey == "" {
fmt.Fprintf(os.Stderr, "PERMIT_KEY environment variable is not set\n")
return
}
client := &http.Client{Timeout: 30 * time.Second}
fmt.Println("Fetching available projects and environments...")
fmt.Println(strings.Repeat("=", 60))
// First, try to get the organization info
orgReq, _ := http.NewRequest("GET", config.PermitBaseURL+"/v2/orgs", nil)
orgReq.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
orgResp, err := client.Do(orgReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching organization info: %v\n", err)
return
}
defer orgResp.Body.Close()
if orgResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(orgResp.Body)
fmt.Fprintf(os.Stderr, "Error response from API: %s\n", string(body))
return
}
// Try to list projects
projReq, _ := http.NewRequest("GET", config.PermitBaseURL+"/v2/projects", nil)
projReq.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
projResp, err := client.Do(projReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching projects: %v\n", err)
return
}
defer projResp.Body.Close()
if projResp.StatusCode == http.StatusOK {
var projects []map[string]interface{}
body, _ := io.ReadAll(projResp.Body)
if err := json.Unmarshal(body, &projects); err == nil {
fmt.Println("Available Projects:")
for _, proj := range projects {
fmt.Printf("\nProject Name: %v\n", proj["name"])
fmt.Printf("Project Key: %v\n", proj["key"])
fmt.Printf("Project ID: %v\n", proj["id"])
// For each project, try to get environments
if projID, ok := proj["id"].(string); ok {
envReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/v2/projects/%s/envs", config.PermitBaseURL, projID), nil)
envReq.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
envResp, err := client.Do(envReq)
if err == nil && envResp.StatusCode == http.StatusOK {
var envs []map[string]interface{}
envBody, _ := io.ReadAll(envResp.Body)
if err := json.Unmarshal(envBody, &envs); err == nil {
fmt.Println(" Environments:")
for _, env := range envs {
fmt.Printf(" - Name: %v\n", env["name"])
fmt.Printf(" Key: %v\n", env["key"])
fmt.Printf(" ID: %v\n", env["id"])
}
}
envResp.Body.Close()
}
}
fmt.Println(strings.Repeat("-", 40))
}
}
} else {
fmt.Println("Unable to list projects. Your API key might be environment-scoped.")
fmt.Println("\nTips for finding your project and environment IDs:")
fmt.Println("1. In the Permit.io dashboard, click on 'Copy Environment Key' - this often shows the context")
fmt.Println("2. Check the API Keys page in Settings - it may show the associated project/environment")
fmt.Println("3. Try using the project 'key' instead of 'name'")
fmt.Println("4. Contact Permit.io support for help identifying your project and environment IDs")
}
}
// ValidateCSVRoles validates that all roles specified in the CSV data exist in the target Permit.io environment.
// It takes the CSV user data, HTTP client, and configuration parameters, then checks if all requested roles
// are already defined in the Permit.io project/environment.
//
// Parameters:
// - client: HTTP client for making API requests
// - config: Application configuration containing Permit.io credentials and project details
// - csvUsers: Slice of user maps from CSV file, each containing user data and roles
//
// Returns:
// - []string: List of roles that are requested in the CSV. If error is nil, these are all valid roles.
// If error is not nil, these are the missing roles that need to be created.
// - error: nil if all roles exist, error describing missing roles if validation fails
func ValidateCSVRoles(client *http.Client, config AppConfig, csvUsers []map[string]string) ([]string, error) {
// Extract all unique roles requested in the CSV
requestedRoles := extractUniqueRolesFromCSV(csvUsers)
if len(requestedRoles) == 0 {
return []string{}, nil // No roles to validate
}
// Get all existing roles from Permit.io
existingRoles, err := getExistingRoles(client, config)
if err != nil {
return requestedRoles, fmt.Errorf("failed to retrieve existing roles from Permit.io: %w", err)
}
// Check which requested roles are missing
missingRoles := findMissingRoles(requestedRoles, existingRoles)
if len(missingRoles) > 0 {
return missingRoles, fmt.Errorf("the following roles do not exist in Permit.io and must be created first: %s",
strings.Join(missingRoles, ", "))
}
// All roles exist
return requestedRoles, nil
}
// extractUniqueRolesFromCSV parses the CSV user data and extracts all unique roles
// mentioned across all users. It handles the comma-separated roles field and
// removes duplicates and empty values.
//
// Parameters:
// - csvUsers: Slice of user maps from CSV file
//
// Returns:
// - []string: Slice of unique role names found in the CSV data
func extractUniqueRolesFromCSV(csvUsers []map[string]string) []string {
roleSet := make(map[string]bool)
// Iterate through all users and collect their roles
for _, user := range csvUsers {
rolesStr, exists := user["roles"]
if !exists || rolesStr == "" {
continue
}
// Split comma-separated roles and add to set
roles := strings.Split(rolesStr, ",")
for _, role := range roles {
role = strings.TrimSpace(role)
if role != "" {
roleSet[role] = true
}
}
}
// Convert set to slice
var uniqueRoles []string
for role := range roleSet {
uniqueRoles = append(uniqueRoles, role)
}
return uniqueRoles
}
// getExistingRoles retrieves all role definitions from the specified Permit.io project/environment.
// It makes an API call to the Permit.io roles endpoint and parses the response to extract role names.
//
// Parameters:
// - client: HTTP client for making API requests
// - config: Application configuration containing API credentials and project details
//
// Returns:
// - []string: Slice of role names that exist in the Permit.io environment
// - error: Error if API call fails or response cannot be parsed
func getExistingRoles(client *http.Client, config AppConfig) ([]string, error) {
// Construct API URL for roles endpoint
url := fmt.Sprintf("%s/v2/schema/%s/%s/roles", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
// Set authorization header
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
req.Header.Set("Content-Type", "application/json")
// Execute request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
// Check for successful response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
// Read and parse response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
// Parse JSON response - try different response structures
var roles []string
var rolesData []interface{}
// First try to unmarshal as direct array
var directRoles []interface{}
if err := json.Unmarshal(body, &directRoles); err == nil {
rolesData = directRoles
} else {
// If that fails, try as object with data/items/etc
var response map[string]interface{}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("error parsing response as object or array: %w", err)
}
// Look for roles in common response structures
if data, ok := response["data"].([]interface{}); ok {
rolesData = data
} else if items, ok := response["items"].([]interface{}); ok {
rolesData = items
} else if results, ok := response["results"].([]interface{}); ok {
rolesData = results
} else {
return nil, fmt.Errorf("unexpected response format: unable to find roles array")
}
}
// Extract role names from the data
for _, roleData := range rolesData {
if roleObj, ok := roleData.(map[string]interface{}); ok {
// Try different possible field names for role identifier
if key, ok := roleObj["key"].(string); ok {
roles = append(roles, key)
} else if name, ok := roleObj["name"].(string); ok {
roles = append(roles, name)
} else if id, ok := roleObj["id"].(string); ok {
roles = append(roles, id)
}
}
}
return roles, nil
}
// findMissingRoles compares the requested roles against existing roles and returns
// a list of roles that are requested but do not exist in the target environment.
//
// Parameters:
// - requestedRoles: Slice of role names that are requested in the CSV
// - existingRoles: Slice of role names that exist in Permit.io
//
// Returns:
// - []string: Slice of role names that are missing (requested but don't exist)
func findMissingRoles(requestedRoles, existingRoles []string) []string {
// Create a set of existing roles for efficient lookup
existingSet := make(map[string]bool)
for _, role := range existingRoles {
existingSet[role] = true
}
// Find requested roles that don't exist
var missingRoles []string
for _, requestedRole := range requestedRoles {
if !existingSet[requestedRole] {
missingRoles = append(missingRoles, requestedRole)
}
}
return missingRoles
}