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:
Executable
+5
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+2
@@ -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 -> human‑readable text (default)
|
||||
// LOG_FORMAT=json -> JSON
|
||||
//
|
||||
// The public API (type AuditLogger and all its methods) is unchanged, so no call‑sites
|
||||
// (e.g. cognito-permit-sync.go) need to be modified.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
const envLogFormat = "LOG_FORMAT"
|
||||
|
||||
// AuditLogger handles audit‑trail 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 dry‑run execution.
|
||||
func (a *AuditLogger) LogDryRunStart() {
|
||||
a.logger.InfoContext(context.TODO(), "dry_run_start")
|
||||
}
|
||||
|
||||
// LogDryRunEnd marks the completion of a dry‑run 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
@@ -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
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user