Merged in feature/permit-io-demo (pull request #144)

draft pr for the test demo harness for permit.io

* harness working

for permit.io demo

* user is const

* aws profile

* permit import code

for research

* cleanup

* create user tool poc

* Merge branch 'main' of bitbucket.org:aarete/query-orchestration into feature/permit-io-demo

* tests for user creation

* add audit logs

and dry-run

* add tests

sanity check the requested roles

* update docs for delete

* disable/enable users

and test

* more tests

and make user name required only for create operations

* clean up

* add docs

* aws tag tests

* audit -> slog

* attempt build fix devbox

* edit

* just devbox

* remove lock change for merge

* Merge branch 'main' of bitbucket.org:aarete/query-orchestration into feature/permit-io-demo
This commit is contained in:
Jay Brown
2025-06-13 19:42:24 +00:00
parent e0d91d17e1
commit daf4d4b94b
501 changed files with 171268 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
docker run -it \
-p 7766:7000 \
--env PDP_API_KEY=permit_key_get-this-value-from-jay \
--env PDP_DEBUG=True \
permitio/pdp-v2:latest
+19
View File
@@ -0,0 +1,19 @@
{
"sub": "e12bb510-30e1-7062-a69a-ca7f3f38d80e",
"cognito:groups": [
"uploaders",
"querybuilders"
],
"iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_1y6po8rR8",
"version": 2,
"client_id": "552cqkf3640t39ncehkmgpce31",
"origin_jti": "0750d18e-8588-4d12-acbd-754d7279f317",
"event_id": "89841577-8dd9-4cd4-a698-a890393305ff",
"token_use": "access",
"scope": "openid profile email",
"auth_time": 1747326253,
"exp": 1747329853,
"iat": 1747326253,
"jti": "739302f1-7334-4718-b124-b51fb958af9a",
"username": "testuser"
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/permitio/permit-golang/pkg/config"
"github.com/permitio/permit-golang/pkg/enforcement"
"github.com/permitio/permit-golang/pkg/permit"
)
const (
port = 4000
readTimeout = 10 * time.Second
writeTimeout = 30 * time.Second
idleTimeout = 120 * time.Second
// This is the subject value from our JWT once we have authenticated.
// Also found in the cognito user pool for the testuser.
testUserSubject = "e12bb510-30e1-7062-a69a-ca7f3f38d80e"
)
func main() {
// Get Permit.io key from environment variable
permitKey := os.Getenv("PERMIT_IO_KEY")
if permitKey == "" {
log.Fatal("PERMIT_IO_KEY environment variable is not set")
}
// Initialize the Permit.io SDK
permitClient := initPermitClient(permitKey)
// Set up routes
mux := setupRoutes(permitClient)
// Start server with timeout settings
startServer(port, mux)
}
// initPermitClient initializes and returns the Permit.io client
func initPermitClient(permitKey string) *permit.Client {
return permit.NewPermit(
config.NewConfigBuilder(permitKey).
WithPdpUrl("https://cloudpdp.api.permit.io").
Build(),
)
}
// setupRoutes configures and returns the HTTP routes
func setupRoutes(permitClient *permit.Client) *http.ServeMux {
mux := http.NewServeMux()
// Register the endpoints
mux.HandleFunc("/query-endpoint", createPermissionHandler(permitClient, "query-endpoint"))
mux.HandleFunc("/admin-stuff", createPermissionHandler(permitClient, "admin-stuff"))
mux.HandleFunc("/", createHomeHandler())
return mux
}
// createHomeHandler returns the handler function for the root path
func createHomeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
_, err := w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<title>Permit.io Test Harness</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
}
a {
display: block;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Permit.io Test Harness</h1>
<h2>Available endpoints:</h2>
<a href="/query-endpoint?action=read">Query Endpoint - Read Action</a>
<a href="/query-endpoint?action=write">Query Endpoint - Write Action</a>
<a href="/admin-stuff?action=read">Admin Stuff - Read Action</a>
<a href="/admin-stuff?action=write">Admin Stuff - Write Action</a>
</body>
</html>
`))
if err != nil {
log.Printf("Error writing response: %v", err)
}
}
}
// createPermissionHandler creates a handler function for permission checks
func createPermissionHandler(permitClient *permit.Client, resourceType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get and validate the action parameter
action, err := validateActionParam(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, writeErr := w.Write([]byte(err.Error()))
if writeErr != nil {
log.Printf("Error writing response: %v", writeErr)
}
return
}
// Check permission
permitted, err := checkPermission(permitClient, testUserSubject, action, resourceType)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, writeErr := w.Write([]byte(fmt.Sprintf("Error checking permissions: %v", err)))
if writeErr != nil {
log.Printf("Error writing response: %v", writeErr)
}
return
}
// Determine response status and color
status, httpStatus, backgroundColor := getResponseStatus(permitted)
// Render the HTML response
renderHTMLResponse(w, resourceType, action, status, httpStatus, backgroundColor)
}
}
// validateActionParam extracts and validates the action parameter from the request
func validateActionParam(r *http.Request) (string, error) {
action := r.URL.Query().Get("action")
if action == "" {
return "", fmt.Errorf("Missing 'action' parameter")
}
return action, nil
}
// checkPermission performs the permission check using the Permit.io client
func checkPermission(permitClient *permit.Client, userID string, action string, resourceType string) (bool, error) {
// Create user for permission check
user := enforcement.UserBuilder(userID).Build()
// Create resource based on the endpoint
resource := enforcement.ResourceBuilder(resourceType).Build()
// Check permission
return permitClient.Check(user, enforcement.Action(action), resource)
}
// getResponseStatus determines the response status based on permission result
func getResponseStatus(permitted bool) (string, int, string) {
if permitted {
return "PERMITTED *********** OK ", http.StatusOK, "#d4ffcc" // Light green
}
return "NOT PERMITTED", http.StatusForbidden, "#ffcccc" // Light red
}
// renderHTMLResponse generates and sends the HTML response
func renderHTMLResponse(w http.ResponseWriter, resourceType, action, status string, httpStatus int, backgroundColor string) {
w.WriteHeader(httpStatus)
w.Header().Set("Content-Type", "text/html")
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>Permit.io Test Result</title>
<style>
body {
background-color: %s;
font-family: Arial, sans-serif;
padding: 20px;
}
</style>
</head>
<body>
<h1>Permission Check Result</h1>
<p>Action '%s' on endpoint '%s' was %s</p>
<p><a href="/">Back to home</a></p>
</body>
</html>
`, backgroundColor, action, resourceType, status)
_, err := w.Write([]byte(html))
if err != nil {
log.Printf("Error writing response: %v", err)
}
}
// startServer starts the HTTP server with proper timeout settings
func startServer(port int, handler http.Handler) {
// Create a server with timeout settings
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: handler,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
// Start the server
fmt.Printf("Listening on http://localhost:%d\n", port)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server error: %v", err)
}
}
+18
View File
@@ -0,0 +1,18 @@
# running the demo
start the permit.io serice
`./demo.sh`
# run the go harness
`./run.main.sh`
# load the main page
load this page and select your actions to test permissions
http://localhost:4000/
# edit the permissions
go to the permissions editor and change rights then re-try on the harness
https://app.permit.io/policy-editor
# example jwt token
See the `jwt.json` file to see the contents of a typical bearer jwt token (decrypted)
to see how the subject (sub) id is identified.
+2
View File
@@ -0,0 +1,2 @@
export PERMIT_IO_KEY="permit_key_get_this_value_from_jay_for_demo"
go run ./...
@@ -0,0 +1,141 @@
// audit.go provides comprehensive audit trail logging functionality for compliance and monitoring.
// This file now uses the `log/slog` structured logging package introduced in Go 1.21.
// A single environment variable LOG_FORMAT controls the output format:
//
// LOG_FORMAT=text -> humanreadable text (default)
// LOG_FORMAT=json -> JSON
//
// The public API (type AuditLogger and all its methods) is unchanged, so no callsites
// (e.g. cognito-permit-sync.go) need to be modified.
package main
import (
"context"
"log/slog"
"os"
)
const envLogFormat = "LOG_FORMAT"
// AuditLogger handles audittrail logging for compliance.
type AuditLogger struct {
logger *slog.Logger
file *os.File
}
// NewAuditLogger initializes the audit logger.
// The signature is unchanged: pass isDryRun=true to write to dry-run-audit.log;
// otherwise logs are appended to audit.log in the working directory.
func NewAuditLogger(isDryRun bool) (*AuditLogger, error) {
filename := "audit.log"
if isDryRun {
filename = "dry-run-audit.log"
}
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
// Choose handler based on LOG_FORMAT
var handler slog.Handler
switch os.Getenv(envLogFormat) {
case "json":
handler = slog.NewJSONHandler(file, &slog.HandlerOptions{AddSource: false})
default: // "text" or unset
handler = slog.NewTextHandler(file, &slog.HandlerOptions{AddSource: false})
}
logger := slog.New(handler)
return &AuditLogger{
logger: logger,
file: file,
}, nil
}
// Close closes the underlying audit file.
func (a *AuditLogger) Close() error {
if a.file != nil {
return a.file.Close()
}
return nil
}
// ----- Public logging helpers ------------------------------------------------
// ActionType represents the type of action being logged.
type ActionType string
const (
ActionCreate ActionType = "CREATE"
ActionDelete ActionType = "DELETE"
ActionDisable ActionType = "DISABLE"
ActionEnable ActionType = "ENABLE"
ActionAssignRole ActionType = "ASSIGN_ROLE"
ActionUnassignRole ActionType = "UNASSIGN_ROLE"
ActionUpdate ActionType = "UPDATE"
ActionSkip ActionType = "SKIP"
)
// SystemType represents the system where the action occurred.
type SystemType string
const (
SystemCognito SystemType = "COGNITO"
SystemPermit SystemType = "PERMIT"
)
// ResultType represents the outcome of the action.
type ResultType string
const (
ResultSuccess ResultType = "SUCCESS"
ResultFailure ResultType = "FAILURE"
ResultSkipped ResultType = "SKIPPED"
)
// LogAction logs a generic action (without subjectID or role).
func (a *AuditLogger) LogAction(action ActionType, system SystemType, userEmail string, result ResultType, details string) {
a.logger.InfoContext(context.TODO(), "audit_event",
"action", action,
"system", system,
"userEmail", userEmail,
"result", result,
"details", details,
)
}
// LogActionWithSubjectID logs an action involving a specific subject identifier.
func (a *AuditLogger) LogActionWithSubjectID(action ActionType, system SystemType, userEmail, subjectID string, result ResultType, details string) {
a.logger.InfoContext(context.TODO(), "audit_event",
"action", action,
"system", system,
"userEmail", userEmail,
"subjectID", subjectID,
"result", result,
"details", details,
)
}
// LogRoleAction logs role assignment/unassignment activities.
func (a *AuditLogger) LogRoleAction(action ActionType, userEmail, role string, result ResultType, details string) {
a.logger.InfoContext(context.TODO(), "audit_event",
"action", action,
"system", SystemPermit,
"userEmail", userEmail,
"role", role,
"result", result,
"details", details,
)
}
// LogDryRunStart marks the beginning of a dryrun execution.
func (a *AuditLogger) LogDryRunStart() {
a.logger.InfoContext(context.TODO(), "dry_run_start")
}
// LogDryRunEnd marks the completion of a dryrun execution.
func (a *AuditLogger) LogDryRunEnd() {
a.logger.InfoContext(context.TODO(), "dry_run_end")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
// cognito.go handles AWS Cognito User Pool operations for user identity management.
// This file provides functions to create, delete, enable, disable, and retrieve users from
// AWS Cognito User Pools, including credential validation, user attribute management,
// and integration with AWS SDK v2 for identity provider operations.
package main
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
)
// Cognito types
type CognitoConfig struct {
UserPoolID string
Region string
}
type UserInput struct {
Email string
FirstName string
LastName string
}
type CognitoUserResponse struct {
SubjectID string
Username string
Email string
FirstName string
LastName string
Status string
Enabled bool
}
func sanityCheckCredentials() {
awsProfile := os.Getenv("AWS_PROFILE")
var cfgOpts []func(*config.LoadOptions) error
if awsProfile != "" {
cfgOpts = append(cfgOpts, config.WithSharedConfigProfile(awsProfile))
}
// Pass options with ... to expand the slice
cfg, err := config.LoadDefaultConfig(context.Background(), cfgOpts...)
if err != nil {
fmt.Fprintf(os.Stderr, "AWS: Failed to load config: %v\n", err)
os.Exit(1)
}
stsSvc := sts.NewFromConfig(cfg)
_, err = stsSvc.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{})
if err != nil {
fmt.Fprintf(os.Stderr, "AWS: Credential check failed: %v\n", err)
os.Exit(1)
}
permitKey := os.Getenv("PERMIT_KEY")
if permitKey == "" {
fmt.Fprintf(os.Stderr, "Permit.io: PERMIT_KEY environment variable is not set\n")
os.Exit(1)
}
req, _ := http.NewRequest("GET", "https://api.permit.io/v2/projects", nil)
req.Header.Add("Authorization", "Bearer "+permitKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "Permit.io: Credential check failed: %v, status=%v\n", err, resp.StatusCode)
os.Exit(1)
}
resp.Body.Close()
}
// Initialize a Cognito client with the correct profile
func initializeCognitoClient() (*cognitoidentityprovider.Client, error) {
awsProfile := os.Getenv("AWS_PROFILE")
var cfgOpts []func(*config.LoadOptions) error
if awsProfile != "" {
cfgOpts = append(cfgOpts, config.WithSharedConfigProfile(awsProfile))
}
cfg, err := config.LoadDefaultConfig(context.Background(), cfgOpts...)
if err != nil {
return nil, err
}
client := cognitoidentityprovider.NewFromConfig(cfg)
return client, nil
}
func createCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, config *CognitoConfig, user map[string]string) (*CognitoUserResponse, error) {
// Use email as username
username := user["email"]
// Prepare user attributes
userAttributes := []types.AttributeType{
{
Name: aws.String("email"),
Value: aws.String(user["email"]),
},
{
Name: aws.String("given_name"),
Value: aws.String(user["first_name"]),
},
{
Name: aws.String("family_name"),
Value: aws.String(user["last_name"]),
},
{
Name: aws.String("email_verified"),
Value: aws.String("true"),
},
}
// Prepare the AdminCreateUser input
createUserInput := &cognitoidentityprovider.AdminCreateUserInput{
UserPoolId: aws.String(config.UserPoolID),
Username: aws.String(username),
UserAttributes: userAttributes,
DesiredDeliveryMediums: []types.DeliveryMediumType{types.DeliveryMediumTypeEmail},
}
// Create the user
result, err := client.AdminCreateUser(ctx, createUserInput)
if err != nil {
return nil, err
}
// Extract the subject ID from attributes
var subjectID string
for _, attr := range result.User.Attributes {
if aws.ToString(attr.Name) == "sub" {
subjectID = aws.ToString(attr.Value)
break
}
}
// Build response
response := &CognitoUserResponse{
SubjectID: subjectID,
Username: aws.ToString(result.User.Username),
Email: user["email"],
FirstName: user["first_name"],
LastName: user["last_name"],
Status: string(result.User.UserStatus),
Enabled: result.User.Enabled,
}
return response, nil
}
func deleteCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) error {
deleteInput := &cognitoidentityprovider.AdminDeleteUserInput{
UserPoolId: aws.String(userPoolID),
Username: aws.String(username),
}
_, err := client.AdminDeleteUser(ctx, deleteInput)
if err != nil {
return err
}
return nil
}
// getCognitoUser retrieves an existing user from Cognito
func getCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) (*CognitoUserResponse, error) {
input := &cognitoidentityprovider.AdminGetUserInput{
UserPoolId: aws.String(userPoolID),
Username: aws.String(username),
}
result, err := client.AdminGetUser(ctx, input)
if err != nil {
return nil, err
}
// Extract attributes
var subjectID, email, firstName, lastName string
for _, attr := range result.UserAttributes {
switch aws.ToString(attr.Name) {
case "sub":
subjectID = aws.ToString(attr.Value)
case "email":
email = aws.ToString(attr.Value)
case "given_name":
firstName = aws.ToString(attr.Value)
case "family_name":
lastName = aws.ToString(attr.Value)
}
}
return &CognitoUserResponse{
SubjectID: subjectID,
Username: aws.ToString(result.Username),
Email: email,
FirstName: firstName,
LastName: lastName,
Status: string(result.UserStatus),
Enabled: result.Enabled,
}, nil
}
// userExistsInCognito checks if a user exists in Cognito
func userExistsInCognito(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) bool {
_, err := getCognitoUser(ctx, client, userPoolID, username)
return err == nil
}
// disableCognitoUser disables a user in Cognito
func disableCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) error {
input := &cognitoidentityprovider.AdminDisableUserInput{
UserPoolId: aws.String(userPoolID),
Username: aws.String(username),
}
_, err := client.AdminDisableUser(ctx, input)
return err
}
// enableCognitoUser enables a user in Cognito
func enableCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) error {
input := &cognitoidentityprovider.AdminEnableUserInput{
UserPoolId: aws.String(userPoolID),
Username: aws.String(username),
}
_, err := client.AdminEnableUser(ctx, input)
return err
}
// isCognitoUserEnabled checks if a user is enabled in Cognito
func isCognitoUserEnabled(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) (bool, error) {
user, err := getCognitoUser(ctx, client, userPoolID, username)
if err != nil {
return false, err
}
return user.Enabled, nil
}
@@ -0,0 +1,453 @@
//go:build aws
package main
import (
"fmt"
"os"
"strings"
"testing"
"time"
)
// Test data for disable/enable operations
var disableTestUsers = []map[string]string{
{
"email": "disable-test1@gmail.com",
"first_name": "DisableTest",
"last_name": "User1",
"roles": "editor",
},
{
"email": "disable-test2@gmail.com",
"first_name": "DisableTest",
"last_name": "User2",
"roles": "viewer",
},
}
const disableTestCSVPath = "./temp-disable-users.csv"
func TestDisableEnableUserLifecycle(t *testing.T) {
// Skip test if required environment variables are not set
if !checkRequiredEnvVars(t) {
return
}
// Create test CSV file for disable/enable tests
if err := createDisableTestCSV(); err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
// Cleanup test CSV file after test
defer func() {
if err := os.Remove(disableTestCSVPath); err != nil && !os.IsNotExist(err) {
t.Logf("Warning: failed to cleanup test CSV file: %v", err)
}
}()
// Test the complete lifecycle: create -> disable -> enable -> delete
t.Run("CreateTestUsers", func(t *testing.T) {
testCreateDisableTestUsers(t)
})
t.Run("VerifyUsersCreated", func(t *testing.T) {
testVerifyDisableTestUsersExist(t)
})
t.Run("DisableUsers", func(t *testing.T) {
testDisableUsers(t)
})
t.Run("VerifyUsersDisabled", func(t *testing.T) {
testVerifyUsersDisabled(t)
})
t.Run("EnableUsers", func(t *testing.T) {
testEnableUsers(t)
})
t.Run("VerifyUsersEnabled", func(t *testing.T) {
testVerifyUsersEnabled(t)
})
t.Run("CleanupTestUsers", func(t *testing.T) {
testDeleteDisableTestUsers(t)
})
}
func createDisableTestCSV() error {
csvContent := "email,first_name,last_name,role1\n"
csvContent += "disable-test1@gmail.com,DisableTest,User1,editor\n"
csvContent += "disable-test2@gmail.com,DisableTest,User2,viewer\n"
return os.WriteFile(disableTestCSVPath, []byte(csvContent), 0600)
}
func testCreateDisableTestUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user creation
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", disableTestCSVPath,
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application
err := run()
if err != nil {
t.Fatalf("Failed to create disable test users: %v", err)
}
t.Logf("Successfully created disable test users")
}
func testVerifyDisableTestUsersExist(t *testing.T) {
ctx := t.Context()
// Initialize clients
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
config := AppConfig{
CognitoUserPoolID: os.Getenv("COGNITO_USER_POOL_ID"),
CognitoRegion: os.Getenv("AWS_REGION"),
}
for _, user := range disableTestUsers {
t.Run(fmt.Sprintf("VerifyUser_%s", user["email"]), func(t *testing.T) {
// Verify user exists in Cognito and is enabled
cognitoUser, err := getCognitoUser(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
if err != nil {
t.Fatalf("Failed to find user %s in Cognito: %v", user["email"], err)
}
if cognitoUser.Status != "CONFIRMED" && cognitoUser.Status != "FORCE_CHANGE_PASSWORD" {
t.Fatalf("User %s is not in expected enabled state. Status: %s", user["email"], cognitoUser.Status)
}
t.Logf("User %s found in Cognito with enabled status: %s", user["email"], cognitoUser.Status)
})
}
}
func testDisableUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user disabling
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", disableTestCSVPath,
"--disable-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in disable mode
err := run()
if err != nil {
t.Fatalf("Failed to disable users: %v", err)
}
t.Logf("Successfully disabled users")
}
func testVerifyUsersDisabled(t *testing.T) {
ctx := t.Context()
// Initialize clients
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
config := AppConfig{
CognitoUserPoolID: os.Getenv("COGNITO_USER_POOL_ID"),
CognitoRegion: os.Getenv("AWS_REGION"),
}
for _, user := range disableTestUsers {
t.Run(fmt.Sprintf("VerifyUserDisabled_%s", user["email"]), func(t *testing.T) {
// Give Cognito a moment to process the disable request
time.Sleep(2 * time.Second)
// Check if the user is disabled using the Enabled field
isEnabled, err := isCognitoUserEnabled(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
if err != nil {
t.Fatalf("Failed to check enabled state for %s: %v", user["email"], err)
}
if isEnabled {
t.Fatalf("User %s is still enabled. Expected disabled state", user["email"])
}
t.Logf("User %s successfully verified as disabled (enabled=%v)", user["email"], isEnabled)
})
}
}
func testEnableUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user enabling
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", disableTestCSVPath,
"--enable-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in enable mode
err := run()
if err != nil {
t.Fatalf("Failed to enable users: %v", err)
}
t.Logf("Successfully enabled users")
}
func testVerifyUsersEnabled(t *testing.T) {
ctx := t.Context()
// Initialize clients
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
config := AppConfig{
CognitoUserPoolID: os.Getenv("COGNITO_USER_POOL_ID"),
CognitoRegion: os.Getenv("AWS_REGION"),
}
for _, user := range disableTestUsers {
t.Run(fmt.Sprintf("VerifyUserEnabled_%s", user["email"]), func(t *testing.T) {
// Give Cognito a moment to process the enable request
time.Sleep(2 * time.Second)
// Check if the user is enabled using the Enabled field
isEnabled, err := isCognitoUserEnabled(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
if err != nil {
t.Fatalf("Failed to check enabled state for %s: %v", user["email"], err)
}
if !isEnabled {
t.Fatalf("User %s is not enabled. Expected enabled state", user["email"])
}
t.Logf("User %s successfully verified as enabled (enabled=%v)", user["email"], isEnabled)
})
}
}
func testDeleteDisableTestUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user deletion
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", disableTestCSVPath,
"--delete-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in delete mode
err := run()
if err != nil {
t.Fatalf("Failed to delete disable test users: %v", err)
}
t.Logf("Successfully deleted disable test users")
}
// Test individual disable/enable functions
func TestDisableEnableOperations(t *testing.T) {
if !checkRequiredEnvVars(t) {
return
}
ctx := t.Context()
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
testEmail := "individual-test@example.com"
// Create a test user first
testUser := map[string]string{
"email": testEmail,
"first_name": "Individual",
"last_name": "Test",
}
cognitoConfig := &CognitoConfig{
UserPoolID: userPoolID,
Region: os.Getenv("AWS_REGION"),
}
// Create user
_, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser)
if err != nil {
// If user already exists, that's fine
if !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "UsernameExistsException") {
t.Fatalf("Failed to create test user: %v", err)
}
}
defer func() {
// Cleanup: delete the test user
_ = deleteCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
}()
t.Run("TestDisableFunction", func(t *testing.T) {
// Test disable function
err := disableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Failed to disable user: %v", err)
}
// Verify user is disabled using the Enabled field
isEnabled, err := isCognitoUserEnabled(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Failed to check enabled state: %v", err)
}
if isEnabled {
t.Fatalf("User should be disabled but is still enabled")
}
t.Logf("User successfully disabled (enabled=%v)", isEnabled)
})
t.Run("TestEnableFunction", func(t *testing.T) {
// Test enable function
err := enableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Failed to enable user: %v", err)
}
// Verify user is enabled using the Enabled field
isEnabled, err := isCognitoUserEnabled(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Failed to check enabled state: %v", err)
}
if !isEnabled {
t.Fatalf("User should be enabled but is still disabled")
}
t.Logf("User successfully enabled (enabled=%v)", isEnabled)
})
}
// Test idempotent behavior
func TestIdempotentDisableEnable(t *testing.T) {
if !checkRequiredEnvVars(t) {
return
}
ctx := t.Context()
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
testEmail := "idempotent-test@example.com"
// Create a test user
testUser := map[string]string{
"email": testEmail,
"first_name": "Idempotent",
"last_name": "Test",
}
cognitoConfig := &CognitoConfig{
UserPoolID: userPoolID,
Region: os.Getenv("AWS_REGION"),
}
_, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser)
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "UsernameExistsException") {
t.Fatalf("Failed to create test user: %v", err)
}
defer func() {
// Cleanup: delete the test user
_ = deleteCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
}()
t.Run("TestDisableIdempotent", func(t *testing.T) {
// Disable user twice - second should be idempotent
err := disableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("First disable failed: %v", err)
}
err = disableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Second disable (idempotent) failed: %v", err)
}
t.Logf("Disable operation is idempotent")
})
t.Run("TestEnableIdempotent", func(t *testing.T) {
// Enable user twice - second should be idempotent
err := enableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("First enable failed: %v", err)
}
err = enableCognitoUser(ctx, cognitoClient, userPoolID, testEmail)
if err != nil {
t.Fatalf("Second enable (idempotent) failed: %v", err)
}
t.Logf("Enable operation is idempotent")
})
}
@@ -0,0 +1,394 @@
//go:build aws
package main
import (
"os"
"testing"
)
const minimalCSVPath = "./temp-minimal-operations.csv"
// Test that the actual CLI operations work with minimal CSV files
func TestMinimalCSVOperations(t *testing.T) {
// Skip test if required environment variables are not set
if !checkRequiredEnvVars(t) {
return
}
// First create some test users that we can later disable/delete
t.Run("CreateUserForMinimalTesting", func(t *testing.T) {
// Create a test user with full data
csvContent := "email,first_name,last_name\n"
csvContent += "minimal-test@gmail.com,Minimal,Test\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user creation
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application to create the user
err = run()
if err != nil {
t.Fatalf("Failed to create test user for minimal CSV testing: %v", err)
}
t.Logf("Successfully created test user for minimal CSV operations")
})
// Test disable operation with minimal CSV (only email)
t.Run("DisableUserWithMinimalCSV", func(t *testing.T) {
// Create minimal CSV with only email
csvContent := "email\n"
csvContent += "minimal-test@gmail.com\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create minimal CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user disabling
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--disable-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in disable mode
err = run()
if err != nil {
t.Fatalf("Failed to disable user with minimal CSV: %v", err)
}
t.Logf("Successfully disabled user with minimal CSV (email only)")
})
// Test enable operation with minimal CSV (only email)
t.Run("EnableUserWithMinimalCSV", func(t *testing.T) {
// Create minimal CSV with only email
csvContent := "email\n"
csvContent += "minimal-test@gmail.com\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create minimal CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user enabling
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--enable-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in enable mode
err = run()
if err != nil {
t.Fatalf("Failed to enable user with minimal CSV: %v", err)
}
t.Logf("Successfully enabled user with minimal CSV (email only)")
})
// Test delete operation with minimal CSV (only email)
t.Run("DeleteUserWithMinimalCSV", func(t *testing.T) {
// Create minimal CSV with only email
csvContent := "email\n"
csvContent += "minimal-test@gmail.com\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create minimal CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user deletion
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--delete-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in delete mode
err = run()
if err != nil {
t.Fatalf("Failed to delete user with minimal CSV: %v", err)
}
t.Logf("Successfully deleted user with minimal CSV (email only)")
})
}
// Test that create operations fail with empty names
func TestCreateOperationValidation(t *testing.T) {
// Skip test if required environment variables are not set
if !checkRequiredEnvVars(t) {
return
}
t.Run("CreateFailsWithEmptyFirstName", func(t *testing.T) {
// Create CSV with empty first name
csvContent := "email,first_name,last_name\n"
csvContent += "validation-test@gmail.com,,Test\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user creation (default mode)
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application - should fail
err = run()
if err == nil {
t.Fatal("Expected error for empty first_name in create mode, but got none")
}
t.Logf("Create operation correctly failed with empty first_name: %v", err)
})
t.Run("CreateFailsWithEmptyLastName", func(t *testing.T) {
// Create CSV with empty last name
csvContent := "email,first_name,last_name\n"
csvContent += "validation-test@gmail.com,Test,\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
// Save original os.Args
originalArgs := os.Args
// Set up args for user creation (default mode)
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application - should fail
err = run()
if err == nil {
t.Fatal("Expected error for empty last_name in create mode, but got none")
}
t.Logf("Create operation correctly failed with empty last_name: %v", err)
})
}
// Test with CSV files that have empty name fields but valid for delete/disable
func TestEmptyNamesForNonCreateOperations(t *testing.T) {
// Skip test if required environment variables are not set
if !checkRequiredEnvVars(t) {
return
}
// First create a test user
t.Run("CreateUserForEmptyNameTesting", func(t *testing.T) {
csvContent := "email,first_name,last_name\n"
csvContent += "empty-names-test@gmail.com,EmptyNames,Test\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
originalArgs := os.Args
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
}
defer func() { os.Args = originalArgs }()
resetFlags()
err = run()
if err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
t.Logf("Successfully created test user for empty names testing")
})
t.Run("DisableWithEmptyNames", func(t *testing.T) {
// CSV with empty names
csvContent := "email,first_name,last_name\n"
csvContent += "empty-names-test@gmail.com,,\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
originalArgs := os.Args
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--disable-users",
}
defer func() { os.Args = originalArgs }()
resetFlags()
err = run()
if err != nil {
t.Fatalf("Failed to disable user with empty names: %v", err)
}
t.Logf("Successfully disabled user with empty names")
})
t.Run("EnableWithEmptyNames", func(t *testing.T) {
// CSV with empty names
csvContent := "email,first_name,last_name\n"
csvContent += "empty-names-test@gmail.com,,\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
originalArgs := os.Args
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--enable-users",
}
defer func() { os.Args = originalArgs }()
resetFlags()
err = run()
if err != nil {
t.Fatalf("Failed to enable user with empty names: %v", err)
}
t.Logf("Successfully enabled user with empty names")
})
t.Run("DeleteWithEmptyNames", func(t *testing.T) {
// CSV with empty names
csvContent := "email,first_name,last_name\n"
csvContent += "empty-names-test@gmail.com,,\n"
err := os.WriteFile(minimalCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(minimalCSVPath)
originalArgs := os.Args
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", minimalCSVPath,
"--delete-users",
}
defer func() { os.Args = originalArgs }()
resetFlags()
err = run()
if err != nil {
t.Fatalf("Failed to delete user with empty names: %v", err)
}
t.Logf("Successfully deleted user with empty names")
})
}
@@ -0,0 +1,419 @@
//go:build aws
package main
import (
"os"
"strings"
"testing"
)
const nameValidationTestCSVPath = "./temp-name-validation.csv"
func TestNameValidationForCreateMode(t *testing.T) {
t.Run("CreateMode_MissingFirstName", func(t *testing.T) {
// CSV with missing first name
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,,Smith\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: false,
}
_, err = readCSV(nameValidationTestCSVPath, config)
if err == nil {
t.Fatal("Expected error for missing first_name in create mode, but got none")
}
if !strings.Contains(err.Error(), "first_name cannot be empty for user creation") {
t.Fatalf("Expected error about missing first_name, got: %v", err)
}
t.Logf("Correctly detected missing first_name: %v", err)
})
t.Run("CreateMode_MissingLastName", func(t *testing.T) {
// CSV with missing last name
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,John,\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: false,
}
_, err = readCSV(nameValidationTestCSVPath, config)
if err == nil {
t.Fatal("Expected error for missing last_name in create mode, but got none")
}
if !strings.Contains(err.Error(), "last_name cannot be empty for user creation") {
t.Fatalf("Expected error about missing last_name, got: %v", err)
}
t.Logf("Correctly detected missing last_name: %v", err)
})
t.Run("CreateMode_MissingBothNames", func(t *testing.T) {
// CSV with missing both names
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,,\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: false,
}
_, err = readCSV(nameValidationTestCSVPath, config)
if err == nil {
t.Fatal("Expected error for missing names in create mode, but got none")
}
if !strings.Contains(err.Error(), "first_name cannot be empty for user creation") {
t.Fatalf("Expected error about missing first_name, got: %v", err)
}
t.Logf("Correctly detected missing first_name: %v", err)
})
t.Run("CreateMode_ValidNames", func(t *testing.T) {
// CSV with valid names
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,John,Smith\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for valid names in create mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
user := users[0]
if user["email"] != "test@example.com" {
t.Fatalf("Expected email 'test@example.com', got '%s'", user["email"])
}
if user["first_name"] != "John" {
t.Fatalf("Expected first_name 'John', got '%s'", user["first_name"])
}
if user["last_name"] != "Smith" {
t.Fatalf("Expected last_name 'Smith', got '%s'", user["last_name"])
}
t.Logf("Valid names accepted correctly for create mode")
})
}
func TestNameValidationForDeleteMode(t *testing.T) {
t.Run("DeleteMode_OnlyEmail", func(t *testing.T) {
// CSV with only email (minimal CSV for delete)
csvContent := "email\n"
csvContent += "test@example.com\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: true,
DisableUsers: false,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for email-only CSV in delete mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
user := users[0]
if user["email"] != "test@example.com" {
t.Fatalf("Expected email 'test@example.com', got '%s'", user["email"])
}
if user["first_name"] != "" {
t.Fatalf("Expected empty first_name, got '%s'", user["first_name"])
}
if user["last_name"] != "" {
t.Fatalf("Expected empty last_name, got '%s'", user["last_name"])
}
t.Logf("Email-only CSV accepted correctly for delete mode")
})
t.Run("DeleteMode_EmptyNames", func(t *testing.T) {
// CSV with empty names (should be allowed for delete)
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,,\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: true,
DisableUsers: false,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for empty names in delete mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
user := users[0]
if user["email"] != "test@example.com" {
t.Fatalf("Expected email 'test@example.com', got '%s'", user["email"])
}
t.Logf("Empty names accepted correctly for delete mode")
})
}
func TestNameValidationForDisableMode(t *testing.T) {
t.Run("DisableMode_OnlyEmail", func(t *testing.T) {
// CSV with only email
csvContent := "email\n"
csvContent += "test@example.com\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: true,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for email-only CSV in disable mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
user := users[0]
if user["email"] != "test@example.com" {
t.Fatalf("Expected email 'test@example.com', got '%s'", user["email"])
}
t.Logf("Email-only CSV accepted correctly for disable mode")
})
t.Run("DisableMode_EmptyNames", func(t *testing.T) {
// CSV with empty names
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,,\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: true,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for empty names in disable mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
t.Logf("Empty names accepted correctly for disable mode")
})
}
func TestNameValidationForEnableMode(t *testing.T) {
t.Run("EnableMode_OnlyEmail", func(t *testing.T) {
// CSV with only email
csvContent := "email\n"
csvContent += "test@example.com\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: true,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for email-only CSV in enable mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
user := users[0]
if user["email"] != "test@example.com" {
t.Fatalf("Expected email 'test@example.com', got '%s'", user["email"])
}
t.Logf("Email-only CSV accepted correctly for enable mode")
})
t.Run("EnableMode_EmptyNames", func(t *testing.T) {
// CSV with empty names
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,,\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: true,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for empty names in enable mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
t.Logf("Empty names accepted correctly for enable mode")
})
}
// Test edge case where CSV has inconsistent field counts
func TestNameValidationEdgeCases(t *testing.T) {
t.Run("CreateMode_InsufficientFields", func(t *testing.T) {
// CSV with insufficient fields for create mode
csvContent := "email,first_name,last_name\n"
csvContent += "test@example.com,John\n" // Missing last_name field
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: false,
DisableUsers: false,
EnableUsers: false,
}
_, err = readCSV(nameValidationTestCSVPath, config)
if err == nil {
t.Fatal("Expected error for insufficient fields in create mode, but got none")
}
if !strings.Contains(err.Error(), "must have at least 3 fields") {
t.Fatalf("Expected error about insufficient fields, got: %v", err)
}
t.Logf("Correctly detected insufficient fields: %v", err)
})
t.Run("DeleteMode_OnlyEmailField", func(t *testing.T) {
// CSV with just email field for delete mode
csvContent := "email\n"
csvContent += "test@example.com\n"
err := os.WriteFile(nameValidationTestCSVPath, []byte(csvContent), 0600)
if err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
defer os.Remove(nameValidationTestCSVPath)
config := AppConfig{
CSVPath: nameValidationTestCSVPath,
DeleteUsers: true,
DisableUsers: false,
EnableUsers: false,
}
users, err := readCSV(nameValidationTestCSVPath, config)
if err != nil {
t.Fatalf("Expected no error for email-only CSV in delete mode, got: %v", err)
}
if len(users) != 1 {
t.Fatalf("Expected 1 user, got %d", len(users))
}
t.Logf("Single email field correctly handled for delete mode")
})
}
@@ -0,0 +1,626 @@
// 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"`
}
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: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %v", 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: %v", 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: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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: %v", err)
}
req.Header.Set("Authorization", "Bearer "+config.PermitAPIKey)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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: %v", err)
}
req, err := http.NewRequest("DELETE", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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
}
@@ -0,0 +1,496 @@
# Cognito + Permit.io User Lifecycle Sync Tool
A CLI tool that creates, updates, deletes, disables, and enables users in AWS Cognito and automatically syncs them to Permit.io with their Cognito Subject ID, maintaining role assignments. The tool is **idempotent** and supports **dry-run mode** with comprehensive **audit logging**.
## Overview
This tool:
1. Reads user data from a CSV file (supports comments with #)
2. **Idempotently** creates or updates users in AWS Cognito
3. **Idempotently** creates or updates corresponding users in Permit.io
4. **Intelligently manages roles** - ensures users have exactly the roles specified in the CSV (removes old roles, adds new ones)
5. Provides comprehensive **audit logging** for compliance
6. Supports **dry-run mode** for testing without side effects
7. Reports success/failure for each step
### Key Features
- **🔄 Idempotent Operations**: Safe to run multiple times with the same data
- **🎯 Smart Role Management**: Automatically adds/removes roles to match CSV specification
- **🔍 Dry-Run Mode**: Test operations without making actual changes
- **📋 Audit Logging**: Complete audit trail for compliance requirements
- **💬 CSV Comments**: Support for comment lines starting with #
- **⚡ Incremental Updates**: Only processes changes, skips existing data
- **🔐 User Access Control**: Disable/enable users in Cognito without deletion
## Operation Modes
### Delete Mode (`--delete-users`)
Runs the inverse of the normal create operation, deleting any users listed in the CSV:
1. Reads user data from a CSV file
2. Finds each user in Permit.io by email (skips if not found)
3. Deletes the user from Permit.io (if exists)
4. Deletes the user from Cognito (if exists)
5. Reports success/failure for each step
**Note**: For delete mode, only email addresses are required in the CSV file (names and roles are optional).
```csv
email,first_name,last_name,admin,viewer
"foo1@gmail.com",,,,
"foo2@gmail.com",,,,
```
### Disable Mode (`--disable-users`)
Disables users in AWS Cognito without deleting them. This prevents users from logging in while preserving their account and settings:
1. Reads user data from a CSV file
2. Checks if each user exists in Cognito
3. Disables the user account in Cognito (if exists and enabled)
4. Reports success/failure for each step
5. **Idempotent**: Reports "already disabled" for users who are already disabled
**Important Notes**:
- Disabled users cannot authenticate or access applications
- User data, roles, and settings are preserved
- Users can be re-enabled later using `--enable-users`
- Only affects Cognito - does not modify Permit.io roles or user data
- **For disable mode, only email addresses are required in the CSV file (names and roles are optional)**
### Enable Mode (`--enable-users`)
Re-enables previously disabled users in AWS Cognito:
1. Reads user data from a CSV file
2. Checks if each user exists in Cognito
3. Enables the user account in Cognito (if exists and disabled)
4. Reports success/failure for each step
5. **Idempotent**: Reports "already enabled" for users who are already enabled
**Important Notes**:
- Only works on existing Cognito users
- Does not create new users - use default mode for user creation
- Only affects Cognito enable/disable status - does not modify roles
- **For enable mode, only email addresses are required in the CSV file (names and roles are optional)**
## Prerequisites
1. **AWS Account** with:
- An existing Cognito User Pool
- Appropriate IAM permissions for Cognito operations
- AWS credentials configured
2. **Permit.io Account** with:
- A project and environment set up
- API key with user creation permissions
## Configuration
### Environment Variables
Create a `.env` file (see `.env.example`):
```bash
# Permit.io Configuration
PERMIT_KEY=your-permit-api-key-here
# AWS Cognito Configuration
COGNITO_USER_POOL_ID=us-east-1_xxxxxxxxx
# AWS Configuration
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key # Or use IAM role
AWS_SECRET_ACCESS_KEY=your-secret-key
# or use profile
AWS_PROFILE=name_of_sso_profile_that_is_already_logged_in
```
### CSV Format
The CSV file should have the following format (no user_id needed):
```csv
email,first_name,last_name,role1,role2,...,roleN
# This is a comment line - will be ignored
john.doe@example.com,John,Doe,admin,viewer
jane.smith@example.com,Jane,Smith,editor,viewer
# Another comment
bob.wilson@example.com,Bob,Wilson,admin
```
- **email**: User's email address (required for all operations, used as Cognito username)
- **first_name**: User's first name (required for create operations only, optional for delete/disable/enable)
- **last_name**: User's last name (required for create operations only, optional for delete/disable/enable)
- **role columns**: Up to 99 role assignments (empty cells are ignored)
- **Comments**: Lines starting with # are ignored
**Important**: When creating users, both `first_name` and `last_name` are required. For delete, disable, and enable operations, only the `email` field is necessary.
## Usage
### Basic Usage
```bash
# Load environment variables
export $(cat .env | xargs)
# Create/update users (idempotent)
./cognito-permit-sync -project myproject -env production -csv users.csv
# Dry run to see what would happen
./cognito-permit-sync -project myproject -env production -csv users.csv --dry-run
# Delete users
./cognito-permit-sync -project myproject -env production -csv users.csv --delete-users
# Disable users (prevents login, preserves account)
./cognito-permit-sync -project myproject -env production -csv users.csv --disable-users
# Enable users (re-enables disabled accounts)
./cognito-permit-sync -project myproject -env production -csv users.csv --enable-users
# Dry run operations (works with all modes)
./cognito-permit-sync -project myproject -env production -csv users.csv --delete-users --dry-run
./cognito-permit-sync -project myproject -env production -csv users.csv --disable-users --dry-run
./cognito-permit-sync -project myproject -env production -csv users.csv --enable-users --dry-run
```
### List Available Projects and Environments
```bash
./cognito-permit-sync -project list
```
### Command Line Options
- `-project`: Permit.io project ID or key (required, or "list" to show available projects)
- `-env`: Permit.io environment ID or key (required)
- `-csv`: Path to CSV file containing users (required)
- `-api-url`: Permit.io API base URL (default: https://api.permit.io)
- `--delete-users`: Delete users instead of creating/updating them
- `--disable-users`: Disable users in Cognito instead of creating/updating them
- `--enable-users`: Enable users in Cognito instead of creating/updating them
- `--dry-run`: Perform a dry run without making actual changes
**Important**: Only one operation mode can be specified at a time (`--delete-users`, `--disable-users`, or `--enable-users`). If none are specified, the tool defaults to create/update mode.
## How It Works
### Create/Update Mode (Default)
For each user in the CSV:
1. **Checks if user exists in Cognito**:
- If exists: Retrieves existing user details
- If not exists: Creates new user with email as username, sets email_verified to true
2. **Checks if user exists in Permit.io**:
- If exists: Uses existing user
- If not exists: Creates user with Cognito Subject ID as key
3. **Manages roles intelligently**:
- Gets current roles assigned to user
- Removes roles that are no longer needed
- Adds new roles specified in CSV
- Skips roles that are already assigned correctly
### Delete Mode (`--delete-users`)
For each user in the CSV:
1. **Finds user in Permit.io by email**:
- If found: Proceeds with deletion
- If not found: Skips Permit.io deletion
2. **Deletes from Permit.io** (if user exists)
3. **Checks if user exists in Cognito**:
- If found: Deletes user
- If not found: Skips Cognito deletion
### Disable Mode (`--disable-users`)
For each user in the CSV:
1. **Checks if user exists in Cognito**:
- If not found: Reports error and skips
- If found: Proceeds with disable check
2. **Checks current user status**:
- If already disabled: Reports "already disabled" and skips
- If enabled: Proceeds with disable operation
3. **Disables user in Cognito**:
- Sets user's enabled status to false
- User cannot authenticate until re-enabled
- All user data and settings are preserved
### Enable Mode (`--enable-users`)
For each user in the CSV:
1. **Checks if user exists in Cognito**:
- If not found: Reports error and skips
- If found: Proceeds with enable check
2. **Checks current user status**:
- If already enabled: Reports "already enabled" and skips
- If disabled: Proceeds with enable operation
3. **Enables user in Cognito**:
- Sets user's enabled status to true
- User can authenticate normally
- All previous settings and roles are restored
### Dry-Run Mode (`--dry-run`)
Works with all operation modes:
- Simulates all operations without making actual API calls
- Shows exactly what would be done
- Logs all actions to `dry-run-audit.log`
- Perfect for testing and validation
- **Recommended**: Always test with `--dry-run` first, especially for disable/enable operations
## Audit Logging
### Audit Files
- **Normal operations**: Logged to `audit.log`
- **Dry-run operations**: Logged to `dry-run-audit.log`
### Audit Log Format
Each audit entry includes:
- Timestamp (YYYY-MM-DD HH:MM:SS)
- Action type (CREATE, DELETE, DISABLE, ENABLE, ASSIGN_ROLE, UNASSIGN_ROLE, SKIP)
- System (COGNITO, PERMIT)
- User identifier (email and/or subject ID)
- Result (SUCCESS, FAILURE, SKIPPED)
- Additional details
### Sample Audit Entries
```
# Create/Update operations
2024-06-04 10:30:15 [CREATE] [COGNITO] [john.doe@example.com|abc123-def456] SUCCESS: User created successfully
2024-06-04 10:30:16 [SKIP] [PERMIT] [john.doe@example.com|abc123-def456] SKIPPED: User already exists
2024-06-04 10:30:17 [UNASSIGN_ROLE] [PERMIT] [john.doe@example.com] SUCCESS: role='old_role', Role removed successfully
2024-06-04 10:30:18 [ASSIGN_ROLE] [PERMIT] [john.doe@example.com] SUCCESS: role='admin', Role assigned successfully
# Disable/Enable operations
2024-06-04 11:15:20 [DISABLE] [COGNITO] [john.doe@example.com] SUCCESS: User disabled successfully
2024-06-04 11:15:21 [SKIP] [COGNITO] [jane.smith@example.com] SKIPPED: User already disabled
2024-06-04 11:20:30 [ENABLE] [COGNITO] [john.doe@example.com] SUCCESS: User enabled successfully
# Delete operations
2024-06-04 12:00:45 [DELETE] [PERMIT] [john.doe@example.com|abc123-def456] SUCCESS: User deleted successfully
2024-06-04 12:00:46 [DELETE] [COGNITO] [john.doe@example.com] SUCCESS: User deleted successfully
```
### Compliance Benefits
- **Complete audit trail** of all user lifecycle operations
- **Immutable log entries** with precise timestamps
- **Action tracking** for both successful and failed operations
- **Role change tracking** for access control compliance
- **Dry-run logging** for change planning and approval processes
## Output Example
```
🔍 DRY RUN MODE - No actual changes will be made
============================================================
Found 2 users to import
Will create users in Cognito User Pool: us-east-2_21upuTkkT
Then sync to Permit.io project: ebde1e1e9623491cab6f8112e67bd61c
============================================================
Processing user 1/2: john.doe@example.com
️ User already exists in Cognito
️ User already exists in Permit.io with key: abc123-def456-789
✓ Removed role: old_admin
🔍 [DRY RUN] Would assign role: editor
️ Role already assigned: viewer
✅ [DRY RUN] User would be successfully processed
Processing user 2/2: jane.smith@example.com
🔍 [DRY RUN] Would create user in Cognito
🔍 [DRY RUN] Would create user in Permit.io
🔍 [DRY RUN] Would assign role: admin
✅ [DRY RUN] User would be successfully processed
============================================================
IMPORT SUMMARY
============================================================
Total users processed: 2
Successfully created: 2
Failed: 0
Time taken: 1.234s
Average time per user: 617ms
🔍 DRY RUN COMPLETED - Check dry-run-audit.log for details
```
## Error Handling
The tool handles various error scenarios gracefully:
- **Duplicate users**: Safely skips creation, proceeds with role management
- **Missing users**: Safely skips deletion if user doesn't exist
- **Partial failures**: Continues processing other users
- **API errors**: Detailed error messages with context
- **Network errors**: Proper error reporting and logging
## Idempotent Behavior
### Safe to Run Multiple Times
- **User Creation**: Won't fail if user already exists
- **Role Management**: Only makes necessary changes (add missing roles, remove extra roles)
- **Partial Recovery**: Can complete partially failed runs
- **No Duplicate Data**: Smart detection prevents duplicate entries
### Role Management Examples
If a user currently has roles `[admin, viewer, old_role]` and CSV specifies `[admin, editor]`:
- ✅ Keeps: `admin` (already assigned)
- Adds: `editor` (new role)
- Removes: `viewer`, `old_role` (no longer needed)
## IAM Permissions Required
Your AWS credentials need these Cognito permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cognito-idp:AdminCreateUser",
"cognito-idp:AdminGetUser",
"cognito-idp:AdminDeleteUser",
"cognito-idp:AdminDisableUser",
"cognito-idp:AdminEnableUser"
],
"Resource": "arn:aws:cognito-idp:REGION:ACCOUNT:userpool/USER_POOL_ID"
}
]
}
```
**New Permissions Required**:
- `cognito-idp:AdminDisableUser`: Required for `--disable-users` functionality
- `cognito-idp:AdminEnableUser`: Required for `--enable-users` functionality
## Best Practices
1. **Always Dry-Run First**: Use `--dry-run` to validate changes before applying
2. **Review Audit Logs**: Check audit logs for compliance and troubleshooting
3. **Incremental Updates**: The tool handles incremental changes efficiently
4. **Backup Strategy**: Keep CSV files versioned for rollback capability
5. **Monitor Performance**: Use built-in timing information for optimization
6. **Test with Small Batches**: Validate with a few users before bulk operations
7. **Disable vs Delete**:
- Use `--disable-users` to temporarily prevent access while preserving user data
- Use `--delete-users` only when permanently removing users
- Disabled users can be re-enabled with `--enable-users`
8. **Staged Deployments**: Test disable/enable operations in development environments first
## Troubleshooting
### "User already exists" (No Longer an Error)
-**Fixed**: Tool now handles existing users gracefully
-**Action**: Continues with role management for existing users
### Audit Log Analysis
```bash
# Check recent audit activity
tail -f audit.log
# Filter for failures
grep "FAILURE" audit.log
# Check role changes
grep "ROLE" audit.log
# Check disable/enable operations
grep "DISABLE\|ENABLE" audit.log
# Analyze dry-run results
cat dry-run-audit.log
```
### Disable/Enable Issues
- **Permission Errors**: Ensure your AWS credentials have `AdminDisableUser` and `AdminEnableUser` permissions
- **User Not Found**: Disable/enable operations only work on existing Cognito users
- **Already Disabled/Enabled**: The tool reports current state - this is normal idempotent behavior
- **Verification**: Check audit logs to confirm operations completed successfully
### Role Synchronization Issues
- **Check audit log** for role assignment/removal details
- **Verify CSV format** for role columns
- **Use dry-run mode** to preview role changes
### API Rate Limiting
- Built-in 100ms delays between operations
- Monitor API response times in console output
- Consider breaking large CSV files into smaller batches
## Development
### Running Tests
Set your .env with the required variables or make them part of your environment:
```bash
PERMIT_KEY="permit_key_redacted..."
COGNITO_USER_POOL_ID="us-east-2_21upuTkkT"
AWS_REGION=us-east-2
AWS_PROFILE=aarete
```
```bash
go test -tags=aws -v ./...
```
### Building for Different Platforms
```bash
# Linux
GOOS=linux GOARCH=amd64 go build -o cognito-permit-sync-linux
# macOS
GOOS=darwin GOARCH=amd64 go build -o cognito-permit-sync-darwin
# Windows
GOOS=windows GOARCH=amd64 go build -o cognito-permit-sync.exe
```
## Recent Improvements ✨
- **✅ Idempotent Operations**: Safe to run multiple times
- **✅ Smart Role Management**: Automatically sync roles to match CSV
- **✅ Audit Logging**: Complete compliance trail with timestamps
- **✅ Dry-Run Mode**: Test changes without side effects
- **✅ CSV Comments**: Support for comment lines with #
- **✅ Better Error Handling**: Graceful handling of missing/existing users
- **✅ Performance Insights**: Detailed timing and success metrics
- **🆕 User Disable/Enable**: Temporarily disable users without deletion
- **🆕 Enhanced Access Control**: Fine-grained user access management
- **🆕 Extended Audit Trail**: Track all disable/enable operations
- **🆕 Idempotent Disable/Enable**: Smart state checking prevents unnecessary operations
## Future Enhancements
- [ ] Batch operations for even better performance
- [ ] Progress bar for large imports
- [ ] Export failed users to CSV for retry
- [ ] Support for custom Cognito attributes
- [ ] Multi-tenant support in Permit.io
- [ ] Configuration file support
- [ ] Integration with CI/CD pipelines
## License
[Your License Here]
+15
View File
@@ -0,0 +1,15 @@
# permit stuff
export PERMIT_KEY="permit_key_redacted"
#
## Cognito stuff
export COGNITO_USER_POOL_ID="us-east-2_21upuTkkT"
#
## aws keys
##export AWS_ACCESS_KEY_ID=
##export AWS_SECRET_ACCESS_KEY=
##export AWS_SESSION_TOKEN=
export AWS_REGION=us-east-2
export AWS_PROFILE=aarete
# must do a project list to get the guid values for the project and environment before making the call to import.
#go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv --delete-users --dry-run
go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv --delete-users
+16
View File
@@ -0,0 +1,16 @@
# permit stuff
export PERMIT_KEY="permit_key_redacted"
# Cognito stuff
export COGNITO_USER_POOL_ID="us-east-2_21upuTkkT"
# aws keys
#export AWS_ACCESS_KEY_ID=
#export AWS_SECRET_ACCESS_KEY=
#export AWS_SESSION_TOKEN=
export AWS_REGION=us-east-2
export AWS_PROFILE=aarete
# must do a project list to get the guid values for the project and environment before making the call to import.
#go run ./... -project list
go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv --dry-run
#go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv
@@ -0,0 +1,356 @@
//go:build aws
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/joho/godotenv"
)
// Test configuration
const (
testCSVPath = "./temp-users.csv"
testProjectID = "ebde1e1e9623491cab6f8112e67bd61c"
testEnvID = "9d6801123cfd4a0ea2ef1df2f430e9d3"
)
// Test data
var testUsers = []map[string]string{
{
"email": "foo1@gmail.com",
"first_name": "john",
"last_name": "smith",
"roles": "editor,viewer",
},
{
"email": "foo2@gmail.com",
"first_name": "jane",
"last_name": "smith",
"roles": "editor,viewer,admin",
},
}
func TestMain(m *testing.M) {
// Load environment variables
if err := godotenv.Overload(); err != nil {
fmt.Println("warning - problem loading .env file - ignoring.")
}
// Run tests
code := m.Run()
// Cleanup
if err := os.Remove(testCSVPath); err != nil && !os.IsNotExist(err) {
fmt.Printf("Warning: failed to cleanup test CSV file: %v\n", err)
}
os.Exit(code)
}
func TestUserLifecycle(t *testing.T) {
// Skip test if required environment variables are not set
if !checkRequiredEnvVars(t) {
return
}
// Create test CSV file
if err := createTestCSV(); err != nil {
t.Fatalf("Failed to create test CSV: %v", err)
}
// Test user creation
t.Run("CreateUsers", func(t *testing.T) {
testCreateUsers(t)
})
// Test user verification
t.Run("VerifyUsersExist", func(t *testing.T) {
testVerifyUsersExist(t)
})
// Test user deletion
t.Run("DeleteUsers", func(t *testing.T) {
testDeleteUsers(t)
})
// Test user deletion verification
t.Run("VerifyUsersDeleted", func(t *testing.T) {
testVerifyUsersDeleted(t)
})
}
func checkRequiredEnvVars(t *testing.T) bool {
required := []string{"PERMIT_KEY", "COGNITO_USER_POOL_ID", "AWS_REGION"}
missing := []string{}
for _, env := range required {
if os.Getenv(env) == "" {
missing = append(missing, env)
}
}
if len(missing) > 0 {
t.Skipf("Skipping integration test - missing required environment variables: %v", missing)
return false
}
return true
}
func createTestCSV() error {
csvContent := "email,first_name,last_name,role1,role2,role3\n"
csvContent += "foo1@gmail.com,john,smith,editor,viewer,\n"
csvContent += "foo2@gmail.com,jane,smith,editor,viewer,admin\n"
return os.WriteFile(testCSVPath, []byte(csvContent), 0600)
}
func testCreateUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user creation
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", testCSVPath,
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application (this will create its own audit logger internally)
err := run()
if err != nil {
t.Fatalf("Failed to create users: %v", err)
}
t.Logf("Successfully created users")
}
func testVerifyUsersExist(t *testing.T) {
ctx := t.Context()
// Initialize clients
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
httpClient := &http.Client{Timeout: 30 * time.Second}
config := AppConfig{
PermitAPIKey: os.Getenv("PERMIT_KEY"),
PermitProjectID: testProjectID,
PermitEnvID: testEnvID,
PermitBaseURL: "https://api.permit.io",
CognitoUserPoolID: os.Getenv("COGNITO_USER_POOL_ID"),
CognitoRegion: os.Getenv("AWS_REGION"),
}
for _, user := range testUsers {
t.Run(fmt.Sprintf("VerifyUser_%s", user["email"]), func(t *testing.T) {
// Verify user exists in Cognito
cognitoSubjectID, err := getCognitoUserSubject(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
if err != nil {
t.Fatalf("Failed to find user %s in Cognito: %v", user["email"], err)
}
if cognitoSubjectID == "" {
t.Fatalf("User %s not found in Cognito", user["email"])
}
t.Logf("User %s found in Cognito with subject ID: %s", user["email"], cognitoSubjectID)
// Verify user exists in Permit.io
permitUserKey, err := findPermitUserByEmail(httpClient, config, user["email"])
if err != nil {
t.Fatalf("Failed to find user %s in Permit.io: %v", user["email"], err)
}
if permitUserKey == "" {
t.Fatalf("User %s not found in Permit.io", user["email"])
}
t.Logf("User %s found in Permit.io with key: %s", user["email"], permitUserKey)
// Verify subject IDs match
if cognitoSubjectID != permitUserKey {
t.Fatalf("Subject ID mismatch for user %s: Cognito=%s, Permit.io=%s",
user["email"], cognitoSubjectID, permitUserKey)
}
t.Logf("Subject IDs match for user %s: %s", user["email"], cognitoSubjectID)
// Verify roles are assigned in Permit.io
if user["roles"] != "" {
expectedRoles := strings.Split(user["roles"], ",")
assignedRoles, err := getPermitUserRoles(httpClient, config, permitUserKey)
if err != nil {
t.Fatalf("Failed to get roles for user %s: %v", user["email"], err)
}
for _, expectedRole := range expectedRoles {
expectedRole = strings.TrimSpace(expectedRole)
if expectedRole == "" {
continue
}
found := false
for _, assignedRole := range assignedRoles {
if assignedRole == expectedRole {
found = true
break
}
}
if !found {
t.Fatalf("Expected role %s not found for user %s. Assigned roles: %v",
expectedRole, user["email"], assignedRoles)
}
}
t.Logf("All expected roles verified for user %s: %v", user["email"], expectedRoles)
}
})
}
}
func testDeleteUsers(t *testing.T) {
// Save original os.Args
originalArgs := os.Args
// Set up args for user deletion
os.Args = []string{
"cognito-permit-sync",
"-project", testProjectID,
"-env", testEnvID,
"-csv", testCSVPath,
"--delete-users",
}
// Restore os.Args after test
defer func() {
os.Args = originalArgs
}()
// Reset flags for testing
resetFlags()
// Run the application in delete mode
err := run()
if err != nil {
t.Fatalf("Failed to delete users: %v", err)
}
t.Logf("Successfully deleted users")
}
func testVerifyUsersDeleted(t *testing.T) {
ctx := t.Context()
// Initialize clients
cognitoClient, err := initializeCognitoClient()
if err != nil {
t.Fatalf("Failed to initialize Cognito client: %v", err)
}
httpClient := &http.Client{Timeout: 30 * time.Second}
config := AppConfig{
PermitAPIKey: os.Getenv("PERMIT_KEY"),
PermitProjectID: testProjectID,
PermitEnvID: testEnvID,
PermitBaseURL: "https://api.permit.io",
CognitoUserPoolID: os.Getenv("COGNITO_USER_POOL_ID"),
CognitoRegion: os.Getenv("AWS_REGION"),
}
for _, user := range testUsers {
t.Run(fmt.Sprintf("VerifyUserDeleted_%s", user["email"]), func(t *testing.T) {
// Verify user is deleted from both systems with retry logic
const maxRetries = 12 // 1 minute with 5-second intervals
const retryInterval = 5 * time.Second
var cognitoDeleted, permitDeleted bool
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
t.Logf("Retry attempt %d/%d for user %s", attempt+1, maxRetries, user["email"])
time.Sleep(retryInterval)
}
// Check Cognito deletion
if !cognitoDeleted {
cognitoSubjectID, err := getCognitoUserSubject(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
cognitoDeleted = (err != nil || cognitoSubjectID == "")
}
// Check Permit.io deletion
if !permitDeleted {
permitUserKey, err := findPermitUserByEmail(httpClient, config, user["email"])
permitDeleted = (err != nil || permitUserKey == "")
}
// If both are deleted, we're done
if cognitoDeleted && permitDeleted {
break
}
}
// Final verification
if !cognitoDeleted {
cognitoSubjectID, _ := getCognitoUserSubject(ctx, cognitoClient, config.CognitoUserPoolID, user["email"])
t.Fatalf("User %s still exists in Cognito after %d attempts with subject ID: %s",
user["email"], maxRetries, cognitoSubjectID)
}
t.Logf("User %s successfully deleted from Cognito", user["email"])
if !permitDeleted {
permitUserKey, _ := findPermitUserByEmail(httpClient, config, user["email"])
t.Fatalf("User %s still exists in Permit.io after %d attempts with key: %s",
user["email"], maxRetries, permitUserKey)
}
t.Logf("User %s successfully deleted from Permit.io", user["email"])
})
}
}
// Helper function to get Cognito user subject ID
func getCognitoUserSubject(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID, username string) (string, error) {
input := &cognitoidentityprovider.AdminGetUserInput{
UserPoolId: aws.String(userPoolID),
Username: aws.String(username),
}
result, err := client.AdminGetUser(ctx, input)
if err != nil {
// Check if it's a UserNotFoundException
if strings.Contains(err.Error(), "UserNotFoundException") ||
strings.Contains(err.Error(), "User does not exist") {
return "", nil // User not found, return empty string
}
return "", err
}
// Extract subject ID from user attributes
for _, attr := range result.UserAttributes {
if aws.ToString(attr.Name) == "sub" {
return aws.ToString(attr.Value), nil
}
}
return "", fmt.Errorf("subject ID not found for user %s", username)
}
// Helper function to reset flags for testing
func resetFlags() {
// Reset the default command line flag set
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}
@@ -0,0 +1,3 @@
email,first_name,last_name,admin,viewer
"foo1@gmail.com",john, smith,editor,viewer
"foo2@gmail.com",jane, smith,editor,viewer, admin
1 email first_name last_name admin viewer
2 foo1@gmail.com john smith editor viewer
3 foo2@gmail.com jane smith editor viewer admin
@@ -0,0 +1,402 @@
//go:build aws
package main
import (
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/joho/godotenv"
)
// Test configuration constants
const (
testProjectIDForRoles = "ebde1e1e9623491cab6f8112e67bd61c"
testEnvIDForRoles = "9d6801123cfd4a0ea2ef1df2f430e9d3"
)
// Replace the existing TestValidateCSVRoles function with these separate functions:
// setupTestConfig creates a test configuration for role validation tests
func setupTestConfig(t *testing.T) (AppConfig, *http.Client, bool) {
// Load environment variables
if err := godotenv.Overload(); err != nil {
t.Logf("Warning: problem loading .env file - ignoring: %v", err)
}
// Skip test if required environment variables are not set
if !checkRequiredEnvVarsForRoles(t) {
return AppConfig{}, nil, false
}
// Setup real HTTP client and configuration
httpClient := &http.Client{Timeout: 30 * time.Second}
config := AppConfig{
PermitAPIKey: os.Getenv("PERMIT_KEY"),
PermitProjectID: testProjectIDForRoles,
PermitEnvID: testEnvIDForRoles,
PermitBaseURL: "https://api.permit.io",
}
return config, httpClient, true
}
func TestValidateCSVRoles_MissingRoles(t *testing.T) {
config, httpClient, ok := setupTestConfig(t)
if !ok {
return
}
// Test CSV data with existing roles (admin, viewer, editor) and one non-existent role 'foo'
csvUsers := []map[string]string{
{
"email": "user1@example.com",
"first_name": "John",
"last_name": "Doe",
"roles": "admin,viewer",
},
{
"email": "user2@example.com",
"first_name": "Jane",
"last_name": "Smith",
"roles": "editor,foo", // 'foo' doesn't exist in real Permit.io
},
{
"email": "user3@example.com",
"first_name": "Bob",
"last_name": "Wilson",
"roles": "viewer",
},
}
// Call the function under test with real API
result, err := ValidateCSVRoles(httpClient, config, csvUsers)
// Verify that an error was returned for missing role 'foo'
if err == nil {
t.Fatalf("Expected error for missing role 'foo', but got nil. Returned roles: %v", result)
}
// Verify the error message mentions the missing role
if !strings.Contains(err.Error(), "foo") {
t.Errorf("Error message should mention missing role 'foo', got: %s", err.Error())
}
// Verify that 'foo' is in the list of missing roles
foundFoo := false
for _, role := range result {
if role == "foo" {
foundFoo = true
break
}
}
if !foundFoo {
t.Errorf("Expected 'foo' to be in missing roles list, got %v", result)
}
// Verify the error message format
if !strings.Contains(err.Error(), "the following roles do not exist in Permit.io") {
t.Errorf("Error message should contain expected prefix, got: %s", err.Error())
}
t.Logf("Successfully detected missing role 'foo'. Missing roles: %v", result)
}
func TestValidateCSVRoles_AllRolesExist(t *testing.T) {
config, httpClient, ok := setupTestConfig(t)
if !ok {
return
}
// Test CSV data with only roles that should exist (admin, viewer, editor)
csvUsers := []map[string]string{
{
"email": "user1@example.com",
"first_name": "John",
"last_name": "Doe",
"roles": "admin,viewer",
},
{
"email": "user2@example.com",
"first_name": "Jane",
"last_name": "Smith",
"roles": "editor,admin",
},
}
// Call the function under test with real API
result, err := ValidateCSVRoles(httpClient, config, csvUsers)
// Verify that no error was returned since all roles should exist
if err != nil {
t.Fatalf("Expected no error when all roles exist, but got: %v", err)
}
// Verify that all unique requested roles are returned
expectedRoleCount := 3 // admin, viewer, editor
if len(result) != expectedRoleCount {
t.Errorf("Expected %d unique roles, got %d: %v", expectedRoleCount, len(result), result)
}
// Verify expected roles are present
expectedRoles := map[string]bool{
"admin": false,
"viewer": false,
"editor": false,
}
for _, role := range result {
if _, exists := expectedRoles[role]; exists {
expectedRoles[role] = true
} else {
t.Errorf("Unexpected role in result: %s", role)
}
}
// Check that all expected roles were found
for role, found := range expectedRoles {
if !found {
t.Errorf("Expected role '%s' not found in result", role)
}
}
t.Logf("Successfully validated all existing roles: %v", result)
}
func TestValidateCSVRoles_EmptyCSV(t *testing.T) {
config, httpClient, ok := setupTestConfig(t)
if !ok {
return
}
// Empty CSV data
csvUsers := []map[string]string{}
// Call the function under test
result, err := ValidateCSVRoles(httpClient, config, csvUsers)
// Verify that no error was returned
if err != nil {
t.Fatalf("Expected no error for empty CSV, but got: %v", err)
}
// Verify that no roles are returned
if len(result) != 0 {
t.Errorf("Expected empty result for empty CSV, got %v", result)
}
t.Logf("Successfully handled empty CSV")
}
func TestValidateCSVRoles_NoRolesInCSV(t *testing.T) {
config, httpClient, ok := setupTestConfig(t)
if !ok {
return
}
// CSV data with users but no roles
csvUsers := []map[string]string{
{
"email": "user1@example.com",
"first_name": "John",
"last_name": "Doe",
"roles": "", // empty roles
},
{
"email": "user2@example.com",
"first_name": "Jane",
"last_name": "Smith",
// no roles field
},
}
// Call the function under test
result, err := ValidateCSVRoles(httpClient, config, csvUsers)
// Verify that no error was returned
if err != nil {
t.Fatalf("Expected no error when no roles specified, but got: %v", err)
}
// Verify that no roles are returned
if len(result) != 0 {
t.Errorf("Expected empty result when no roles specified, got %v", result)
}
t.Logf("Successfully handled CSV with no roles")
}
// TestExtractUniqueRolesFromCSV tests the role extraction helper function
func TestExtractUniqueRolesFromCSV(t *testing.T) {
t.Run("ExtractUniqueRoles_NormalCase", func(t *testing.T) {
csvUsers := []map[string]string{
{"roles": "admin,viewer"},
{"roles": "editor,admin"}, // admin is duplicate
{"roles": "viewer"}, // viewer is duplicate
{"roles": ""}, // empty roles
}
result := extractUniqueRolesFromCSV(csvUsers)
// Should get 3 unique roles
expectedCount := 3
if len(result) != expectedCount {
t.Errorf("Expected %d unique roles, got %d: %v", expectedCount, len(result), result)
}
// Check that all expected roles are present
expectedRoles := map[string]bool{
"admin": false,
"viewer": false,
"editor": false,
}
for _, role := range result {
if _, exists := expectedRoles[role]; exists {
expectedRoles[role] = true
} else {
t.Errorf("Unexpected role: %s", role)
}
}
// Verify all expected roles were found
for role, found := range expectedRoles {
if !found {
t.Errorf("Expected role '%s' not found", role)
}
}
})
t.Run("ExtractUniqueRoles_WithWhitespace", func(t *testing.T) {
csvUsers := []map[string]string{
{"roles": " admin , viewer "}, // roles with whitespace
{"roles": "editor, admin "}, // more whitespace
}
result := extractUniqueRolesFromCSV(csvUsers)
// Verify whitespace is trimmed and duplicates removed
expectedRoles := map[string]bool{
"admin": false,
"viewer": false,
"editor": false,
}
if len(result) != 3 {
t.Errorf("Expected 3 unique roles, got %d: %v", len(result), result)
}
for _, role := range result {
if _, exists := expectedRoles[role]; exists {
expectedRoles[role] = true
} else {
t.Errorf("Unexpected role: %s", role)
}
}
// Verify all expected roles were found
for role, found := range expectedRoles {
if !found {
t.Errorf("Expected role '%s' not found", role)
}
}
})
}
// TestFindMissingRoles tests the missing roles detection helper function
func TestFindMissingRoles(t *testing.T) {
t.Run("FindMissingRoles_SomeMissing", func(t *testing.T) {
requestedRoles := []string{"admin", "viewer", "foo", "bar"}
existingRoles := []string{"admin", "viewer", "editor"}
result := findMissingRoles(requestedRoles, existingRoles)
expectedMissing := map[string]bool{
"foo": false,
"bar": false,
}
if len(result) != 2 {
t.Errorf("Expected 2 missing roles, got %d: %v", len(result), result)
}
for _, role := range result {
if _, exists := expectedMissing[role]; exists {
expectedMissing[role] = true
} else {
t.Errorf("Unexpected missing role: %s", role)
}
}
// Verify all expected missing roles were found
for role, found := range expectedMissing {
if !found {
t.Errorf("Expected missing role '%s' not found", role)
}
}
})
t.Run("FindMissingRoles_NoneMissing", func(t *testing.T) {
requestedRoles := []string{"admin", "viewer"}
existingRoles := []string{"admin", "viewer", "editor"}
result := findMissingRoles(requestedRoles, existingRoles)
if len(result) != 0 {
t.Errorf("Expected no missing roles, got %v", result)
}
})
t.Run("FindMissingRoles_AllMissing", func(t *testing.T) {
requestedRoles := []string{"foo", "bar"}
existingRoles := []string{"admin", "viewer", "editor"}
result := findMissingRoles(requestedRoles, existingRoles)
if len(result) != 2 {
t.Errorf("Expected 2 missing roles, got %d: %v", len(result), result)
}
expectedMissing := map[string]bool{
"foo": false,
"bar": false,
}
for _, role := range result {
if _, exists := expectedMissing[role]; exists {
expectedMissing[role] = true
} else {
t.Errorf("Unexpected missing role: %s", role)
}
}
// Verify all expected missing roles were found
for role, found := range expectedMissing {
if !found {
t.Errorf("Expected missing role '%s' not found", role)
}
}
})
}
// checkRequiredEnvVarsForRoles checks if required environment variables are set for role validation tests
func checkRequiredEnvVarsForRoles(t *testing.T) bool {
required := []string{"PERMIT_KEY"}
missing := []string{}
for _, env := range required {
if os.Getenv(env) == "" {
missing = append(missing, env)
}
}
if len(missing) > 0 {
t.Skipf("Skipping role validation integration test - missing required environment variables: %v", missing)
return false
}
return true
}