diff --git a/README.md b/README.md index dd195a4..876553e 100644 --- a/README.md +++ b/README.md @@ -147,28 +147,95 @@ The Feature Flag Backend project is built using the following technologies and p - Deployment: Serverless (AWS Lambda) - Deployment Automation: GitHub Actions +## Authentication + +The Feature Flag Backend uses JWT-based authentication. All endpoints (except registration, login, and health check) require authentication via JWT token in the cookie header. + +### User Registration and Login + +- `POST /users/register` - Register a new user (no authentication required) +- `POST /users/login` - Login and receive JWT token (no authentication required) + +### Authentication Flow + +1. Register a user via `POST /users/register` +2. Login via `POST /users/login` to receive a JWT token +3. Include the JWT token in the `Cookie` header for subsequent requests: + ``` + Cookie: rds-session-staging= + ``` + +## Role-Based Access Control (RBAC) + +The system implements role-based access control with three roles: + +### Roles + +- **ADMIN**: Full access to all operations +- **DEVELOPER**: Can create/update feature flags and manage user mappings, read-only user management +- **VIEWER**: Read-only access to feature flags and own user mappings + +### Permission Matrix + +| Operation | ADMIN | DEVELOPER | VIEWER | +|-----------|-------|-----------|--------| +| Create Feature Flag | ✅ | ✅ | ❌ | +| Update Feature Flag | ✅ | ✅ | ❌ | +| Read Feature Flag | ✅ | ✅ | ✅ | +| Create User Mapping | ✅ | ✅ | ❌ | +| Update User Mapping | ✅ | ✅ | ❌ | +| Read User Mapping | ✅ | ✅ | ✅ (own only) | +| Read User | ✅ | ✅ | ✅ (own only) | +| Update User | ✅ | ❌ | ❌ (own profile only) | + +### Resource Ownership + +- Users can access their own resources (feature flag mappings, profile) +- ADMIN can access all resources +- Non-ADMIN users cannot access other users' resources + ## API Endpoints The API endpoints available in the Feature Flag Backend project are as follows: -- GET `/feature-flags` to get all the feature flags -- POST `/feature-flags` to create a feature flag -- GET `/feature-flags/{flagId}` to get the feature flag with an ID -- PATCH `/feature-flags/{flagId}` to update a feature flag -- GET `/users/{userId}/feature-flags/{flagId}` to get a feature flag details for a user -- GET `/users/{userId}/feature-flags/` to get all feature flag details for a user -- POST `/users/{userId}/feature-flags/{flagId}` to create a feature flag for a user -- PATCH `/users/{userId}/feature-flags/{flagId}` to update a feature flag for a user -- OPTIONS `/` to serve all the preflight requests made by the browser -- GET `/health-check` to know uptime of the system -- POST `/reset-limit` to update the counter of the rate limiting logic (`requestLimit`) ddb table -- PATCH `/mark-concurrency-zero` this is used to make the concurrency of all other lambdas to zero +### Feature Flag Management +- `GET /feature-flags` - Get all feature flags (Requires: READ_FEATURE_FLAG) +- `POST /feature-flags` - Create a feature flag (Requires: CREATE_FEATURE_FLAG - ADMIN, DEVELOPER) +- `GET /feature-flags/{flagId}` - Get feature flag by ID (Requires: READ_FEATURE_FLAG) +- `PATCH /feature-flags/{flagId}` - Update a feature flag (Requires: UPDATE_FEATURE_FLAG - ADMIN, DEVELOPER) + +### User Feature Flag Mappings +- `GET /users/{userId}/feature-flags/{flagId}` - Get feature flag details for a user (Requires: READ_USER_MAPPING + ownership) +- `GET /users/{userId}/feature-flags/` - Get all feature flag details for a user (Requires: READ_USER_MAPPING + ownership) +- `POST /users/{userId}/feature-flags/{flagId}` - Create a feature flag mapping for a user (Requires: CREATE_USER_MAPPING + ownership - ADMIN, DEVELOPER) +- `PATCH /users/{userId}/feature-flags/{flagId}` - Update a feature flag mapping for a user (Requires: UPDATE_USER_MAPPING + ownership - ADMIN, DEVELOPER) + +### User Management +- `GET /users/{userId}` - Get user by ID (Requires: READ_USER + ownership) +- `PUT /users/{userId}` - Update user profile (Requires: UPDATE_USER + ownership, role changes require ADMIN) + +### System Endpoints +- `OPTIONS /` - Serve all preflight requests made by the browser +- `GET /health-check` - Check system uptime (no authentication required) +- `POST /reset-limit` - Update the counter of the rate limiting logic (`requestLimit`) ddb table +- `PATCH /mark-concurrency-zero` - Set the concurrency of all other lambdas to zero For more detailed information about the API contracts, please refer to the [API contract](./openapi.yaml). ## Data Model -The Feature Flag Backend project uses DynamoDB as the database. The data model consists of two main entities: +The Feature Flag Backend project uses DynamoDB as the database. The data model consists of the following entities: + +### User +- id (string) **Partition key** +- email (string) **Global Secondary Index (email-index)** +- passwordHash (string) +- role (string) - ADMIN, DEVELOPER, or VIEWER +- createdAt (number) +- createdBy (string) +- updatedAt (number) +- updatedBy (string) +- isActive (boolean) ### FeatureFlag - id (string) **Partition key** @@ -180,10 +247,9 @@ The Feature Flag Backend project uses DynamoDB as the database. The data model c - updatedBy (string) - status (string) - ### FeatureFlagUserMapping -- userId (string) **Global Secondary Index** -- flagId (string) **Partition key** +- userId (string) **Partition key** +- flagId (string) **Sort key** - status (string) - createdAt (number) - createdBy (string) @@ -191,7 +257,7 @@ The Feature Flag Backend project uses DynamoDB as the database. The data model c - updatedBy (string) ### requestLimit -- limitType (string) (partitionKey) +- limitType (string) **Partition key** - limitValue (number) ## Deployment instructions diff --git a/layer/utils/RBAC_test.go b/layer/utils/RBAC_test.go new file mode 100644 index 0000000..df6cb43 --- /dev/null +++ b/layer/utils/RBAC_test.go @@ -0,0 +1,314 @@ +package utils + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHasPermission(t *testing.T) { + tests := []struct { + name string + role string + permission Permission + expected bool + }{ + { + name: "ADMIN has CREATE_FEATURE_FLAG permission", + role: ROLE_ADMIN, + permission: PermissionCreateFeatureFlag, + expected: true, + }, + { + name: "DEVELOPER has CREATE_FEATURE_FLAG permission", + role: ROLE_DEVELOPER, + permission: PermissionCreateFeatureFlag, + expected: true, + }, + { + name: "VIEWER does not have CREATE_FEATURE_FLAG permission", + role: ROLE_VIEWER, + permission: PermissionCreateFeatureFlag, + expected: false, + }, + { + name: "ADMIN has DELETE_FEATURE_FLAG permission", + role: ROLE_ADMIN, + permission: PermissionDeleteFeatureFlag, + expected: true, + }, + { + name: "DEVELOPER does not have DELETE_FEATURE_FLAG permission", + role: ROLE_DEVELOPER, + permission: PermissionDeleteFeatureFlag, + expected: false, + }, + { + name: "VIEWER has READ_FEATURE_FLAG permission", + role: ROLE_VIEWER, + permission: PermissionReadFeatureFlag, + expected: true, + }, + { + name: "ADMIN has UPDATE_USER permission", + role: ROLE_ADMIN, + permission: PermissionUpdateUser, + expected: true, + }, + { + name: "DEVELOPER does not have UPDATE_USER permission", + role: ROLE_DEVELOPER, + permission: PermissionUpdateUser, + expected: false, + }, + { + name: "Unknown role has no permissions", + role: "UNKNOWN_ROLE", + permission: PermissionReadFeatureFlag, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := HasPermission(test.role, test.permission) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestRequirePermission(t *testing.T) { + tests := []struct { + name string + userContext *UserContext + permission Permission + expectedStatus int + expectedError error + }{ + { + name: "ADMIN with valid permission", + userContext: &UserContext{ + UserId: "admin-123", + Role: ROLE_ADMIN, + Email: "admin@example.com", + }, + permission: PermissionCreateFeatureFlag, + expectedStatus: http.StatusOK, + expectedError: nil, + }, + { + name: "VIEWER without required permission", + userContext: &UserContext{ + UserId: "viewer-123", + Role: ROLE_VIEWER, + Email: "viewer@example.com", + }, + permission: PermissionCreateFeatureFlag, + expectedStatus: http.StatusForbidden, + expectedError: nil, + }, + { + name: "Nil user context", + userContext: nil, + permission: PermissionReadFeatureFlag, + expectedStatus: http.StatusUnauthorized, + expectedError: nil, + }, + { + name: "DEVELOPER with valid permission", + userContext: &UserContext{ + UserId: "dev-123", + Role: ROLE_DEVELOPER, + Email: "dev@example.com", + }, + permission: PermissionUpdateFeatureFlag, + expectedStatus: http.StatusOK, + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := RequirePermission(test.userContext, test.permission) + assert.Equal(t, test.expectedStatus, response.StatusCode) + assert.Equal(t, test.expectedError, err) + }) + } +} + +func TestRequireAnyPermission(t *testing.T) { + tests := []struct { + name string + userContext *UserContext + permissions []Permission + expectedStatus int + expectedError error + }{ + { + name: "ADMIN with one of the permissions", + userContext: &UserContext{ + UserId: "admin-123", + Role: ROLE_ADMIN, + Email: "admin@example.com", + }, + permissions: []Permission{PermissionCreateFeatureFlag, PermissionDeleteFeatureFlag}, + expectedStatus: http.StatusOK, + expectedError: nil, + }, + { + name: "VIEWER without any of the permissions", + userContext: &UserContext{ + UserId: "viewer-123", + Role: ROLE_VIEWER, + Email: "viewer@example.com", + }, + permissions: []Permission{PermissionCreateFeatureFlag, PermissionDeleteFeatureFlag}, + expectedStatus: http.StatusForbidden, + expectedError: nil, + }, + { + name: "Nil user context", + userContext: nil, + permissions: []Permission{PermissionReadFeatureFlag}, + expectedStatus: http.StatusUnauthorized, + expectedError: nil, + }, + { + name: "DEVELOPER with one valid permission", + userContext: &UserContext{ + UserId: "dev-123", + Role: ROLE_DEVELOPER, + Email: "dev@example.com", + }, + permissions: []Permission{PermissionCreateFeatureFlag, PermissionDeleteFeatureFlag}, + expectedStatus: http.StatusOK, + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := RequireAnyPermission(test.userContext, test.permissions...) + assert.Equal(t, test.expectedStatus, response.StatusCode) + assert.Equal(t, test.expectedError, err) + }) + } +} + +func TestCanAccessUserResource(t *testing.T) { + tests := []struct { + name string + userContext *UserContext + resourceUserId string + expected bool + }{ + { + name: "User accessing own resource", + userContext: &UserContext{ + UserId: "user-123", + Role: ROLE_VIEWER, + Email: "user@example.com", + }, + resourceUserId: "user-123", + expected: true, + }, + { + name: "ADMIN accessing other user's resource", + userContext: &UserContext{ + UserId: "admin-123", + Role: ROLE_ADMIN, + Email: "admin@example.com", + }, + resourceUserId: "user-456", + expected: true, + }, + { + name: "VIEWER accessing other user's resource", + userContext: &UserContext{ + UserId: "viewer-123", + Role: ROLE_VIEWER, + Email: "viewer@example.com", + }, + resourceUserId: "user-456", + expected: false, + }, + { + name: "DEVELOPER accessing other user's resource", + userContext: &UserContext{ + UserId: "dev-123", + Role: ROLE_DEVELOPER, + Email: "dev@example.com", + }, + resourceUserId: "user-456", + expected: false, + }, + { + name: "Nil user context", + userContext: nil, + resourceUserId: "user-123", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := CanAccessUserResource(test.userContext, test.resourceUserId) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestRolePermissions(t *testing.T) { + // Test that all roles have expected permissions + tests := []struct { + role string + permission Permission + shouldHave bool + }{ + // ADMIN permissions + {ROLE_ADMIN, PermissionCreateFeatureFlag, true}, + {ROLE_ADMIN, PermissionUpdateFeatureFlag, true}, + {ROLE_ADMIN, PermissionReadFeatureFlag, true}, + {ROLE_ADMIN, PermissionDeleteFeatureFlag, true}, + {ROLE_ADMIN, PermissionCreateUserMapping, true}, + {ROLE_ADMIN, PermissionUpdateUserMapping, true}, + {ROLE_ADMIN, PermissionReadUserMapping, true}, + {ROLE_ADMIN, PermissionReadUser, true}, + {ROLE_ADMIN, PermissionUpdateUser, true}, + {ROLE_ADMIN, PermissionDeleteUser, true}, + + // DEVELOPER permissions + {ROLE_DEVELOPER, PermissionCreateFeatureFlag, true}, + {ROLE_DEVELOPER, PermissionUpdateFeatureFlag, true}, + {ROLE_DEVELOPER, PermissionReadFeatureFlag, true}, + {ROLE_DEVELOPER, PermissionDeleteFeatureFlag, false}, + {ROLE_DEVELOPER, PermissionCreateUserMapping, true}, + {ROLE_DEVELOPER, PermissionUpdateUserMapping, true}, + {ROLE_DEVELOPER, PermissionReadUserMapping, true}, + {ROLE_DEVELOPER, PermissionReadUser, true}, + {ROLE_DEVELOPER, PermissionUpdateUser, false}, + {ROLE_DEVELOPER, PermissionDeleteUser, false}, + + // VIEWER permissions + {ROLE_VIEWER, PermissionCreateFeatureFlag, false}, + {ROLE_VIEWER, PermissionUpdateFeatureFlag, false}, + {ROLE_VIEWER, PermissionReadFeatureFlag, true}, + {ROLE_VIEWER, PermissionDeleteFeatureFlag, false}, + {ROLE_VIEWER, PermissionCreateUserMapping, false}, + {ROLE_VIEWER, PermissionUpdateUserMapping, false}, + {ROLE_VIEWER, PermissionReadUserMapping, true}, + {ROLE_VIEWER, PermissionReadUser, true}, + {ROLE_VIEWER, PermissionUpdateUser, false}, + {ROLE_VIEWER, PermissionDeleteUser, false}, + } + + for _, test := range tests { + t.Run(test.role+"_"+string(test.permission), func(t *testing.T) { + result := HasPermission(test.role, test.permission) + assert.Equal(t, test.shouldHave, result, "Role %s should %s have permission %s", + test.role, map[bool]string{true: "", false: "not"}[test.shouldHave], test.permission) + }) + } +} +