diff --git a/CLAUDE.md b/CLAUDE.md index 85acce8..671d6e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ The complete API is documented in OpenAPI 3.0 format: ``` api/ # HTTP handlers and routes accounts/ # Account management endpoints + apikey/ # API key management endpoints health/ # Health check endpoint problem/ # RFC 7807 problem details (standardized errors) rbac/ # RBAC endpoints @@ -103,6 +104,9 @@ internal/ # Business logic and data access accounts.go # Service interfaces and implementations mongodb_account_repository.go # Repository implementation error.go # Domain-specific errors + apikey/ # API key domain + apikey.go # ApiKey model, service interface and implementation + mongodb_apikey_repository.go # Repository implementation rbac/ # RBAC domain roles.go # Role/Permission services and repositories permissions.go # Permission implementation @@ -261,6 +265,30 @@ type Permission struct { } ``` +### ApiKey Structure + +```go +type ApiKey struct { + ID string // UUID + TenantID string // Reference to tenant + AccountID string // Reference to account + Name string // Unique per account per tenant + KeyHash string // Bcrypt-hashed key (plaintext only shown once at creation) + KeyPrefix string // "api_" + IsEnabled bool // Key active status + Expires *time.Time // Optional expiration date + Created time.Time + Modified time.Time +} +``` + +**Important Notes:** +- API keys are scoped to both tenant and account (multi-tenant isolation) +- Key is hashed with bcrypt before storage; plaintext returned only at creation as `"api_:"` +- Unique composite index on `{tenantId, accountId, name}` +- Supports suspend/enable lifecycle via `IsEnabled` flag +- Revoke performs hard deletion + ## API Endpoints All routes are under `/api/v1/` prefix. @@ -276,6 +304,15 @@ All routes are under `/api/v1/` prefix. - `PUT /accounts/deactivate` - Soft delete account - `PUT /accounts/unlink` - Unlink social provider +### API Key Management (Tenant-scoped: `/api/v1/tenant/:tenantid/accounts/:id/apikeys`) +**Requires:** Tenant admin or system admin role in the tenant +- `POST /accounts/:id/apikeys` - Generate new API key (returns plaintext key once) +- `GET /accounts/:id/apikeys` - List all API keys for account +- `GET /accounts/:id/apikeys/:apiKeyID` - Get API key details +- `PUT /accounts/:id/apikeys/:apiKeyID/suspend` - Suspend API key +- `PUT /accounts/:id/apikeys/:apiKeyID/enable` - Enable API key +- `DELETE /accounts/:id/apikeys/:apiKeyID` - Revoke (delete) API key + ### RBAC Management (Tenant-scoped: `/api/v1/tenant/:tenantid/rbac`) **Requires:** Tenant admin or system admin role in the tenant - `POST /roles` - Create role @@ -474,24 +511,27 @@ All tenant-scoped routes automatically require either `tenant_admin` or `bulwark - **Email templates** - Per-tenant email template management - **Authentication** - JWT validation with tenant context extraction - **CORS middleware** - Configurable cross-origin support +- **API key management** - Generate, list, get, suspend, enable, and revoke API keys per account - **Comprehensive testing** - Unit tests + integration tests for all domains - Account tests: 3 unit tests + 7 integration tests + - API key tests: 6 unit tests + 8 integration tests - RBAC tests: 2 unit tests + 10 integration tests - Tenant tests: 8 unit tests + 12 integration tests - Middleware tests: 6 unit tests for authorization helpers - Tenant admin tests: 6 integration tests - - **All 28 integration tests passing** ✅ + - **All 39 integration tests passing** ✅ - **CI/CD** - Automated versioning, building, and Docker image publishing - **Docker Compose compatible** - Works with both `docker-compose` and `docker compose` commands ### Active Branch - Main branch: `main` -- Current feature branch: `feat-tenant-admin` +- Current feature branch: `feat-api-key` ## MongoDB Collections - **tenants** - Multi-tenant configuration with unique name index - **accounts** - User accounts (per tenant) with unique email index per tenant +- **apiKeys** - API keys (per account per tenant) with unique composite index on {tenantId, accountId, name} - **roles** - RBAC roles (per tenant) - **permissions** - RBAC permissions (per tenant) - **email_templates** - Email templates (per tenant) diff --git a/README.md b/README.md index 0b894c0..15fe9e4 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,16 @@ Base: `/api/v1/tenant/:tenantid/accounts` - `PUT /accounts/deactivate` - Soft delete account - `PUT /accounts/unlink` - Unlink social provider +### API Key Management +Base: `/api/v1/tenant/:tenantid/accounts/:id/apikeys` + +- `POST /accounts/:id/apikeys` - Generate new API key (returns plaintext key once) +- `GET /accounts/:id/apikeys` - List all API keys for account +- `GET /accounts/:id/apikeys/:apiKeyID` - Get API key details +- `PUT /accounts/:id/apikeys/:apiKeyID/suspend` - Suspend API key +- `PUT /accounts/:id/apikeys/:apiKeyID/enable` - Enable API key +- `DELETE /accounts/:id/apikeys/:apiKeyID` - Revoke (delete) API key + ### RBAC Management Base: `/api/v1/tenant/:tenantid/rbac` @@ -309,6 +319,7 @@ Base: `/api/v1/admin/tenants` (requires system admin role) ``` api/ # HTTP handlers and routes accounts/ # Account management endpoints + apikey/ # API key management endpoints health/ # Health check endpoint problem/ # RFC 7807 problem details (errors) rbac/ # RBAC endpoints @@ -316,6 +327,7 @@ api/ # HTTP handlers and routes internal/ # Business logic and data access accounts/ # Account domain + apikey/ # API key domain rbac/ # RBAC domain tenants/ # Tenant domain email/ # Email template management diff --git a/api/accounts/apikey/accounts_handler.go b/api/accounts/apikey/accounts_handler.go new file mode 100644 index 0000000..ee41297 --- /dev/null +++ b/api/accounts/apikey/accounts_handler.go @@ -0,0 +1,179 @@ +package apikey + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/latebit-io/bulwarkauthadmin/api/middleware" + "github.com/latebit-io/bulwarkauthadmin/api/problem" + "github.com/latebit-io/bulwarkauthadmin/internal/accounts" + "github.com/latebit-io/bulwarkauthadmin/internal/accounts/apikey" +) + +type NewApiKeyRequest struct { + Name string `json:"name"` +} + +type AccountApiKeyHandler struct { + apikey apikey.ApiKeyService +} + +func NewAccountApiKeyHandler(apikey apikey.ApiKeyService) *AccountApiKeyHandler { + return &AccountApiKeyHandler{ + apikey: apikey, + } +} + +func (h *AccountApiKeyHandler) CreateApiKey(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + newApiRequest := new(NewApiKeyRequest) + err := c.Bind(newApiRequest) + if err != nil { + httpError := problem.NewBadRequest(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + if newApiRequest.Name == "" { + httpError := problem.NewBadRequest(errors.New("name is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + apiKey, err := h.apikey.Generate(ctx, tenantID, accountID, newApiRequest.Name, nil) + if err != nil { + var apiKeyDuplicateError accounts.ApiKeyDuplicateError + duplicate := errors.As(err, &apiKeyDuplicateError) + if duplicate { + return echo.NewHTTPError(http.StatusConflict, problem.Details{ + Type: "https://latebit.io/bulwark/errors/", + Title: "Duplicate Api Key", + Status: http.StatusConflict, + Detail: err.Error(), + }) + } + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.JSON(http.StatusCreated, apiKey) +} + +func (h *AccountApiKeyHandler) RevokeApiKey(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + apiKeyID := c.Param("apiKeyID") + if apiKeyID == "" { + httpError := problem.NewBadRequest(errors.New("api key is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + err := h.apikey.Revoke(ctx, apiKeyID, tenantID, accountID) + if err != nil { + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *AccountApiKeyHandler) SuspendApiKey(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + apiKeyID := c.Param("apiKeyID") + if apiKeyID == "" { + httpError := problem.NewBadRequest(errors.New("api key is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + err := h.apikey.Suspend(ctx, apiKeyID, tenantID, accountID) + if err != nil { + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *AccountApiKeyHandler) EnableApiKey(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + apiKeyID := c.Param("apiKeyID") + if apiKeyID == "" { + httpError := problem.NewBadRequest(errors.New("api key is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + err := h.apikey.Enable(ctx, apiKeyID, tenantID, accountID) + if err != nil { + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *AccountApiKeyHandler) ListApiKeys(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + keys, err := h.apikey.List(ctx, tenantID, accountID) + if err != nil { + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.JSON(http.StatusOK, keys) +} + +func (h *AccountApiKeyHandler) GetApiKey(c echo.Context) error { + tenantID := middleware.GetTenantIDFromEcho(c) + accountID := c.Param("id") + if accountID == "" { + httpError := problem.NewBadRequest(errors.New("account is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + apiKeyID := c.Param("apiKeyID") + if apiKeyID == "" { + httpError := problem.NewBadRequest(errors.New("api key is required")) + return echo.NewHTTPError(httpError.Status, httpError) + } + + ctx := c.Request().Context() + key, err := h.apikey.GetKey(ctx, apiKeyID, tenantID, accountID) + if err != nil { + httpError := problem.NewServerError(err) + return echo.NewHTTPError(httpError.Status, httpError) + } + + return c.JSON(http.StatusOK, key) +} diff --git a/api/accounts/apikey/accounts_routes.go b/api/accounts/apikey/accounts_routes.go new file mode 100644 index 0000000..cdfa0c3 --- /dev/null +++ b/api/accounts/apikey/accounts_routes.go @@ -0,0 +1,12 @@ +package apikey + +import "github.com/labstack/echo/v4" + +func AccountApiKeyRoutesV1(e *echo.Group, handler *AccountApiKeyHandler) { + e.POST("/accounts/:id/apikeys", handler.CreateApiKey) + e.DELETE("/accounts/:id/apikeys/:apiKeyID", handler.RevokeApiKey) + e.PUT("/accounts/:id/apikeys/:apiKeyID/enable", handler.EnableApiKey) + e.PUT("/accounts/:id/apikeys/:apiKeyID/suspend", handler.SuspendApiKey) + e.GET("/accounts/:id/apikeys", handler.ListApiKeys) + e.GET("/accounts/:id/apikeys/:apiKeyID", handler.GetApiKey) +} diff --git a/cmd/bulwarkauthadmin/main.go b/cmd/bulwarkauthadmin/main.go index a707686..9d53cec 100644 --- a/cmd/bulwarkauthadmin/main.go +++ b/cmd/bulwarkauthadmin/main.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/echo/v4/middleware" bulwark "github.com/latebit-io/bulwark-auth-guard" accountsapi "github.com/latebit-io/bulwarkauthadmin/api/accounts" + apikeyapi "github.com/latebit-io/bulwarkauthadmin/api/accounts/apikey" accountsrbacapi "github.com/latebit-io/bulwarkauthadmin/api/accounts/rbac" "github.com/latebit-io/bulwarkauthadmin/api/health" bulwarkauthmiddleware "github.com/latebit-io/bulwarkauthadmin/api/middleware" @@ -22,10 +23,12 @@ import ( tenantsapi "github.com/latebit-io/bulwarkauthadmin/api/tenants" "github.com/latebit-io/bulwarkauthadmin/internal/accounts" adminAccount "github.com/latebit-io/bulwarkauthadmin/internal/accounts/admin" + "github.com/latebit-io/bulwarkauthadmin/internal/accounts/apikey" accountsRbac "github.com/latebit-io/bulwarkauthadmin/internal/accounts/rbac" "github.com/latebit-io/bulwarkauthadmin/internal/email" "github.com/latebit-io/bulwarkauthadmin/internal/rbac" "github.com/latebit-io/bulwarkauthadmin/internal/tenants" + "github.com/latebit-io/bulwarkauthadmin/internal/utils" "github.com/latebit-io/bulwarkauthadmin/internal/version" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" @@ -115,6 +118,11 @@ func main() { accountsManagmentService := accounts.NewAccountManagementServiceDefault(accountRepository) accountsHandler := accountsapi.NewAccountHandler(accountsManagmentService) accountsapi.AccountRoutesV1(tenantGroup, accountsHandler) + encryption := utils.NewDefaultEncryption(12) + apiKeyRepository := apikey.NewMongoDBApiKeyRepository(mongodb) + apiKeyService := apikey.NewApiKeyServiceDefault(apiKeyRepository, encryption) + apiKeyHandler := apikeyapi.NewAccountApiKeyHandler(apiKeyService) + apikeyapi.AccountApiKeyRoutesV1(tenantGroup, apiKeyHandler) roleService := rbac.NewRoleServiceDefault(rolesRepository) permissionService := rbac.NewPermissionServiceDefault(permissionsRepository) diff --git a/internal/accounts/apikey/apikey.go b/internal/accounts/apikey/apikey.go new file mode 100644 index 0000000..80ed0bd --- /dev/null +++ b/internal/accounts/apikey/apikey.go @@ -0,0 +1,118 @@ +package apikey + +import ( + "context" + "fmt" + "time" + + "github.com/latebit-io/bulwarkauthadmin/internal/utils" + + "github.com/google/uuid" +) + +const apiKeyPrefix = "api_" + +type ApiKey struct { + ID string `json:"id" bson:"id"` + TenantID string `json:"tenantId" bson:"tenantId"` + AccountID string `json:"accountId" bson:"accountId"` + Name string `json:"name" bson:"name"` + KeyHash string `json:"-" bson:"key"` + KeyPrefix string `json:"keyPrefix" bson:"keyPrefix"` + IsEnabled bool `json:"isEnabled" bson:"isEnabled"` + Expires *time.Time `json:"expires" bson:"expires"` + Created time.Time `json:"created" bson:"created"` + Modified time.Time `json:"modified" bson:"modified"` +} + +type ApiKeyRepository interface { + Create(ctx context.Context, tenantID, accountID string, key *ApiKey) error + Read(ctx context.Context, id, tenantID, accountID string) (*ApiKey, error) + ReadAll(ctx context.Context, tenantID, accountID string) ([]*ApiKey, error) + Update(ctx context.Context, tenantID, accountID string, key *ApiKey) error + Delete(ctx context.Context, id, tenantID, accountID string) error +} + +type ApiKeyService interface { + Generate(ctx context.Context, tenantID, accountID, name string, expire *time.Time) (string, error) + Suspend(ctx context.Context, ID, tenantID, accountID string) error + Enable(ctx context.Context, ID, tenantID, accountID string) error + Revoke(ctx context.Context, ID, tenantID, accountID string) error + List(ctx context.Context, tenantID, accountID string) ([]*ApiKey, error) + GetKey(ctx context.Context, id, tenantID, accountID string) (*ApiKey, error) +} + +type ApiKeyServiceDefault struct { + repo ApiKeyRepository + encryption utils.Encryption +} + +func NewApiKeyServiceDefault(repo ApiKeyRepository, encryption utils.Encryption) ApiKeyService { + return &ApiKeyServiceDefault{ + repo: repo, + encryption: encryption, + } +} + +func (s *ApiKeyServiceDefault) Generate(ctx context.Context, tenantID, accountID, name string, expire *time.Time) (string, error) { + id := uuid.New().String() + key := uuid.New().String() + hashKey, err := s.encryption.Encrypt(key) + if err != nil { + return "", err + } + apiKey := &ApiKey{ + ID: id, + TenantID: tenantID, + AccountID: accountID, + Name: name, + KeyHash: hashKey, + KeyPrefix: apiKeyPrefix, + IsEnabled: true, + Expires: expire, + Created: time.Now(), + Modified: time.Now(), + } + + if err := s.repo.Create(ctx, tenantID, accountID, apiKey); err != nil { + return "", err + } + + return fmt.Sprintf("%s:%s", apiKey.KeyPrefix, key), nil +} + +func (s *ApiKeyServiceDefault) Suspend(ctx context.Context, ID, tenantID, accountID string) error { + key, err := s.repo.Read(ctx, ID, tenantID, accountID) + if err != nil { + return err + } + + key.IsEnabled = false + key.Modified = time.Now() + + return s.repo.Update(ctx, tenantID, accountID, key) +} + +func (s *ApiKeyServiceDefault) Enable(ctx context.Context, ID, tenantID, accountID string) error { + key, err := s.repo.Read(ctx, ID, tenantID, accountID) + if err != nil { + return err + } + + key.IsEnabled = true + key.Modified = time.Now() + + return s.repo.Update(ctx, tenantID, accountID, key) +} + +func (s *ApiKeyServiceDefault) Revoke(ctx context.Context, ID, tenantID, accountID string) error { + return s.repo.Delete(ctx, ID, tenantID, accountID) +} + +func (s *ApiKeyServiceDefault) List(ctx context.Context, tenantID, accountID string) ([]*ApiKey, error) { + return s.repo.ReadAll(ctx, tenantID, accountID) +} + +func (s *ApiKeyServiceDefault) GetKey(ctx context.Context, id, tenantID, accountID string) (*ApiKey, error) { + return s.repo.Read(ctx, id, tenantID, accountID) +} diff --git a/internal/accounts/apikey/mongodb_apikey_repository.go b/internal/accounts/apikey/mongodb_apikey_repository.go new file mode 100644 index 0000000..acdafec --- /dev/null +++ b/internal/accounts/apikey/mongodb_apikey_repository.go @@ -0,0 +1,115 @@ +package apikey + +import ( + "context" + "errors" + "log" + + "github.com/latebit-io/bulwarkauthadmin/internal/accounts" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + collectionName = "apiKeys" +) + +type MongoDBApiKeyRepository struct { + db *mongo.Database +} + +// Create implements ApiKeyRepository. +func (m *MongoDBApiKeyRepository) Create(ctx context.Context, tenantID, accountID string, key *ApiKey) error { + key.AccountID = accountID + key.TenantID = tenantID + collection := m.db.Collection(collectionName) + _, err := collection.InsertOne(ctx, key) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return accounts.ApiKeyDuplicateError{ + Value: key.Name, + } + } + return err + } + + return nil +} + +// Delete implements ApiKeyRepository. +func (m *MongoDBApiKeyRepository) Delete(ctx context.Context, id, tenantID, accountID string) error { + collection := m.db.Collection(collectionName) + result, err := collection.DeleteOne(ctx, bson.M{"id": id, "tenantId": tenantID, "accountId": accountID}) + if err != nil { + return err + } + if result.DeletedCount == 0 { + return accounts.ApiKeyNotFoundError{ + Value: id, + } + } + return nil +} + +// Read implements ApiKeyRepository. +func (m *MongoDBApiKeyRepository) Read(ctx context.Context, id, tenantID, accountID string) (*ApiKey, error) { + collection := m.db.Collection(collectionName) + result := collection.FindOne(ctx, bson.M{"id": id, "tenantId": tenantID, "accountId": accountID}) + var key ApiKey + err := result.Decode(&key) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, accounts.ApiKeyNotFoundError{Value: id} + } + return nil, err + } + + return &key, nil +} + +// Update implements ApiKeyRepository. +func (m *MongoDBApiKeyRepository) Update(ctx context.Context, tenantID, accountID string, key *ApiKey) error { + collection := m.db.Collection(collectionName) + _, err := collection.UpdateOne(ctx, bson.M{"id": key.ID, "tenantId": tenantID, "accountId": accountID}, bson.M{"$set": key}) + return err +} + +func (m *MongoDBApiKeyRepository) ReadAll(ctx context.Context, tenantID, accountID string) ([]*ApiKey, error) { + collection := m.db.Collection(collectionName) + cursor, err := collection.Find(ctx, bson.M{"tenantId": tenantID, "accountId": accountID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var keys []*ApiKey + for cursor.Next(ctx) { + var key ApiKey + if err := cursor.Decode(&key); err != nil { + return nil, err + } + keys = append(keys, &key) + } + + if err := cursor.Err(); err != nil { + return nil, err + } + + return keys, nil +} + +func NewMongoDBApiKeyRepository(db *mongo.Database) ApiKeyRepository { + collection := db.Collection(collectionName) + _, err := collection.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: "tenantId", Value: 1}, {Key: "accountId", Value: 1}, {Key: "name", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + log.Fatal(err) + } + + return &MongoDBApiKeyRepository{ + db: db, + } +} diff --git a/internal/accounts/apikey/mongodb_apikey_repository_test.go b/internal/accounts/apikey/mongodb_apikey_repository_test.go new file mode 100644 index 0000000..005b0a4 --- /dev/null +++ b/internal/accounts/apikey/mongodb_apikey_repository_test.go @@ -0,0 +1,205 @@ +package apikey + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/latebit-io/bulwarkauthadmin/internal/accounts" + "github.com/latebit-io/bulwarkauthadmin/internal/utils" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const testTenantID = "00000000-0000-0000-0000-000000000001" +const testAccountID = "00000000-0000-0000-0000-000000000010" + +func setupMongoServer(t *testing.T) (*mongo.Client, *mongo.Database) { + mongodb := utils.NewMongoTestUtil() + mongoServer, err := mongodb.CreateServer() + if err != nil { + t.Fatal(err) + } + + clientOptions := options.Client().ApplyURI(mongoServer.URI()) + client, err := mongo.Connect(context.TODO(), clientOptions) + if err != nil { + t.Fatal(err) + } + + return client, client.Database("bulwark") +} + +func cleanupMongoServer(t *testing.T, client *mongo.Client) { + err := client.Disconnect(context.TODO()) + if err != nil { + t.Fatal(err) + } +} + +func newTestApiKey(tenantID, accountID, name string) *ApiKey { + expires := time.Now().Add(24 * time.Hour) + return &ApiKey{ + ID: uuid.New().String(), + TenantID: tenantID, + AccountID: accountID, + Name: name, + KeyHash: "hashed_key_value", + KeyPrefix: "api_", + IsEnabled: true, + Expires: &expires, + Created: time.Now(), + Modified: time.Now(), + } +} + +func TestMongoDBApiKeyRepository_Create(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + t.Run("Valid ApiKey", func(t *testing.T) { + key := newTestApiKey(testTenantID, testAccountID, "my-key") + err := repo.Create(context.TODO(), testTenantID, testAccountID, key) + assert.NoError(t, err) + }) +} + +func TestMongoDBApiKeyRepository_Read(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + t.Run("Valid Read", func(t *testing.T) { + key := newTestApiKey(testTenantID, testAccountID, "read-key") + err := repo.Create(context.TODO(), testTenantID, testAccountID, key) + assert.NoError(t, err) + + found, err := repo.Read(context.TODO(), key.ID, testTenantID, testAccountID) + assert.NoError(t, err) + assert.Equal(t, key.ID, found.ID) + assert.Equal(t, key.Name, found.Name) + assert.Equal(t, testTenantID, found.TenantID) + assert.Equal(t, testAccountID, found.AccountID) + }) + + t.Run("Not Found", func(t *testing.T) { + _, err := repo.Read(context.TODO(), "nonexistent-id", testTenantID, testAccountID) + assert.Error(t, err) + }) +} + +func TestMongoDBApiKeyRepository_Update(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + t.Run("Update IsEnabled", func(t *testing.T) { + key := newTestApiKey(testTenantID, testAccountID, "update-key") + err := repo.Create(context.TODO(), testTenantID, testAccountID, key) + assert.NoError(t, err) + + key.IsEnabled = false + key.Modified = time.Now() + err = repo.Update(context.TODO(), testTenantID, testAccountID, key) + assert.NoError(t, err) + + updated, err := repo.Read(context.TODO(), key.ID, testTenantID, testAccountID) + assert.NoError(t, err) + assert.False(t, updated.IsEnabled) + }) +} + +func TestMongoDBApiKeyRepository_Delete(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + t.Run("Valid Delete", func(t *testing.T) { + key := newTestApiKey(testTenantID, testAccountID, "delete-key") + err := repo.Create(context.TODO(), testTenantID, testAccountID, key) + assert.NoError(t, err) + + err = repo.Delete(context.TODO(), key.ID, testTenantID, testAccountID) + assert.NoError(t, err) + + _, err = repo.Read(context.TODO(), key.ID, testTenantID, testAccountID) + assert.Error(t, err) + }) + + t.Run("Delete Nonexistent Key", func(t *testing.T) { + err := repo.Delete(context.TODO(), "nonexistent-id", testTenantID, testAccountID) + assert.Error(t, err) + assert.IsType(t, accounts.ApiKeyNotFoundError{}, err) + }) +} + +func TestMongoDBApiKeyRepository_TenantIsolation(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + tenant1 := "00000000-0000-0000-0000-000000000001" + tenant2 := "00000000-0000-0000-0000-000000000002" + + key1 := newTestApiKey(tenant1, testAccountID, "shared-name") + key2 := newTestApiKey(tenant2, testAccountID, "shared-name") + + err := repo.Create(context.TODO(), tenant1, testAccountID, key1) + assert.NoError(t, err) + + err = repo.Create(context.TODO(), tenant2, testAccountID, key2) + assert.NoError(t, err) + + // Read from tenant1 should only find key1 + found, err := repo.Read(context.TODO(), key1.ID, tenant1, testAccountID) + assert.NoError(t, err) + assert.Equal(t, tenant1, found.TenantID) + + // Read key1 from tenant2 should fail + _, err = repo.Read(context.TODO(), key1.ID, tenant2, testAccountID) + assert.Error(t, err) + + // Delete from tenant1 should not affect tenant2 + err = repo.Delete(context.TODO(), key1.ID, tenant1, testAccountID) + assert.NoError(t, err) + + found, err = repo.Read(context.TODO(), key2.ID, tenant2, testAccountID) + assert.NoError(t, err) + assert.Equal(t, tenant2, found.TenantID) +} + +func TestMongoDBApiKeyRepository_AccountIsolation(t *testing.T) { + client, db := setupMongoServer(t) + defer cleanupMongoServer(t, client) + + repo := NewMongoDBApiKeyRepository(db) + + account1 := "00000000-0000-0000-0000-000000000010" + account2 := "00000000-0000-0000-0000-000000000020" + + key1 := newTestApiKey(testTenantID, account1, "my-key") + key2 := newTestApiKey(testTenantID, account2, "my-key") + + err := repo.Create(context.TODO(), testTenantID, account1, key1) + assert.NoError(t, err) + + err = repo.Create(context.TODO(), testTenantID, account2, key2) + assert.NoError(t, err) + + // Read key1 with account2 should fail + _, err = repo.Read(context.TODO(), key1.ID, testTenantID, account2) + assert.Error(t, err) + + // Each account can read their own key + found, err := repo.Read(context.TODO(), key1.ID, testTenantID, account1) + assert.NoError(t, err) + assert.Equal(t, account1, found.AccountID) +} diff --git a/internal/accounts/error.go b/internal/accounts/error.go index 9cf47ae..cae39ef 100644 --- a/internal/accounts/error.go +++ b/internal/accounts/error.go @@ -25,3 +25,19 @@ type SocialProviderNotFoundError struct { func (e SocialProviderNotFoundError) Error() string { return fmt.Sprintf("social provider not found: '%s'", e.Value) } + +type ApiKeyDuplicateError struct { + Value string `json:"value"` +} + +func (e ApiKeyDuplicateError) Error() string { + return fmt.Sprintf("duplicate apiKey: '%s' already exists", e.Value) +} + +type ApiKeyNotFoundError struct { + Value string `json:"value"` +} + +func (e ApiKeyNotFoundError) Error() string { + return fmt.Sprintf("apiKey not found: '%s'", e.Value) +} diff --git a/internal/utils/encryption.go b/internal/utils/encryption.go new file mode 100644 index 0000000..963d27b --- /dev/null +++ b/internal/utils/encryption.go @@ -0,0 +1,48 @@ +package utils + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +type Encryption interface { + Encrypt(password string) (string, error) + Verify(password, verifyPassword string) (bool, error) +} + +type DefaultEncryption struct { + cost int +} + +// NewDefaultEncryption creates a new instance of DefaultEncryption with the specified cost. +// Default cost is 12, if set to lower than 12, it will be set to 12. +func NewDefaultEncryption(cost int) Encryption { + if cost < 12 { + cost = 12 + } + return &DefaultEncryption{ + cost: cost, + } +} + +func (d DefaultEncryption) Encrypt(password string) (string, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), d.cost) + if err != nil { + return "", err + } + return string(hashed), nil +} + +func (d DefaultEncryption) Verify(password, verifyPassword string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(password), []byte(verifyPassword)) + if err != nil { + // If the password doesn't match, return false without error + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + // For other bcrypt errors (invalid hash format, etc.), return the error + return false, err + } + return true, nil +} diff --git a/openapi.yaml b/openapi.yaml index 573131a..68d4d51 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -23,6 +23,8 @@ tags: description: Service health checks - name: Accounts description: Account management and lifecycle operations + - name: API Keys + description: API key management for accounts - name: RBAC description: Role and permission management - name: Account RBAC @@ -38,7 +40,7 @@ paths: tags: - Health responses: - '200': + "200": description: Service is healthy content: application/json: @@ -79,24 +81,24 @@ paths: type: string description: Tenant domain responses: - '201': + "201": description: Tenant created successfully content: application/json: schema: - $ref: '#/components/schemas/Tenant' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': + $ref: "#/components/schemas/Tenant" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": description: Tenant name already exists content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" get: summary: List all tenants @@ -106,18 +108,18 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: List of tenants content: application/json: schema: type: array items: - $ref: '#/components/schemas/Tenant' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: "#/components/schemas/Tenant" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /admin/tenants/{tenantId}: get: @@ -135,18 +137,18 @@ paths: type: string description: Tenant ID (UUID) responses: - '200': + "200": description: Tenant details content: application/json: schema: - $ref: '#/components/schemas/Tenant' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/Tenant" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" put: summary: Update tenant @@ -175,20 +177,20 @@ paths: Domain: type: string responses: - '200': + "200": description: Tenant updated successfully content: application/json: schema: - $ref: '#/components/schemas/Tenant' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/Tenant" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" delete: summary: Delete tenant @@ -204,14 +206,14 @@ paths: schema: type: string responses: - '204': + "204": description: Tenant deleted successfully - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts: post: @@ -241,24 +243,24 @@ paths: format: email description: Account email address responses: - '201': + "201": description: Account created successfully content: application/json: schema: - $ref: '#/components/schemas/Account' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': + $ref: "#/components/schemas/Account" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": description: Email already exists in this tenant content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" get: summary: List accounts in tenant @@ -286,18 +288,18 @@ paths: default: 10 description: Page size responses: - '200': + "200": description: List of accounts content: application/json: schema: type: array items: - $ref: '#/components/schemas/Account' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: "#/components/schemas/Account" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /tenant/{tenantId}/accounts/{accountId}: get: @@ -319,18 +321,18 @@ paths: schema: type: string responses: - '200': + "200": description: Account details content: application/json: schema: - $ref: '#/components/schemas/AccountDetails' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/AccountDetails" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/email: put: @@ -364,16 +366,16 @@ paths: format: email description: New email address responses: - '200': + "200": description: Email changed successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/disable: put: @@ -401,16 +403,16 @@ paths: AccountId: type: string responses: - '200': + "200": description: Account disabled successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/enable: put: @@ -438,16 +440,16 @@ paths: AccountId: type: string responses: - '200': + "200": description: Account enabled successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/deactivate: put: @@ -475,16 +477,16 @@ paths: AccountId: type: string responses: - '200': + "200": description: Account deactivated successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/unlink: put: @@ -516,16 +518,240 @@ paths: type: string description: Social provider name (e.g., google, github) responses: - '200': + "200": description: Social provider unlinked successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /tenant/{tenantId}/accounts/{accountId}/apikeys: + post: + summary: Generate a new API key + description: Creates a new API key for the account. The plaintext key is returned only once in the response. + operationId: createApiKey + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: Unique name for the API key within this account + responses: + "201": + description: API key created successfully. Returns the plaintext key (shown only once). + content: + application/json: + schema: + type: string + example: "api_:550e8400-e29b-41d4-a716-446655440000" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: API key name already exists for this account + content: + application/json: + schema: + $ref: "#/components/schemas/ProblemDetails" + + get: + summary: List API keys for account + operationId: listApiKeys + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + responses: + "200": + description: List of API keys + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApiKey" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /tenant/{tenantId}/accounts/{accountId}/apikeys/{apiKeyId}: + get: + summary: Get API key details + operationId: getApiKey + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + - name: apiKeyId + in: path + required: true + schema: + type: string + responses: + "200": + description: API key details + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKey" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + delete: + summary: Revoke (delete) API key + operationId: revokeApiKey + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + - name: apiKeyId + in: path + required: true + schema: + type: string + responses: + "204": + description: API key revoked successfully + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /tenant/{tenantId}/accounts/{accountId}/apikeys/{apiKeyId}/suspend: + put: + summary: Suspend API key + operationId: suspendApiKey + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + - name: apiKeyId + in: path + required: true + schema: + type: string + responses: + "204": + description: API key suspended successfully + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /tenant/{tenantId}/accounts/{accountId}/apikeys/{apiKeyId}/enable: + put: + summary: Enable API key + operationId: enableApiKey + tags: + - API Keys + security: + - BearerAuth: [] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + - name: accountId + in: path + required: true + schema: + type: string + - name: apiKeyId + in: path + required: true + schema: + type: string + responses: + "204": + description: API key enabled successfully + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/rbac/roles: post: @@ -557,24 +783,24 @@ paths: Description: type: string responses: - '201': + "201": description: Role created successfully content: application/json: schema: - $ref: '#/components/schemas/Role' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': + $ref: "#/components/schemas/Role" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": description: Role already exists content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" get: summary: List roles in tenant @@ -600,18 +826,18 @@ paths: type: integer default: 10 responses: - '200': + "200": description: List of roles content: application/json: schema: type: array items: - $ref: '#/components/schemas/Role' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: "#/components/schemas/Role" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /tenant/{tenantId}/rbac/roles/{roleName}: get: @@ -633,18 +859,18 @@ paths: schema: type: string responses: - '200': + "200": description: Role details content: application/json: schema: - $ref: '#/components/schemas/Role' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/Role" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" put: summary: Update role @@ -674,20 +900,20 @@ paths: Description: type: string responses: - '200': + "200": description: Role updated successfully content: application/json: schema: - $ref: '#/components/schemas/Role' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/Role" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" delete: summary: Delete role @@ -708,14 +934,14 @@ paths: schema: type: string responses: - '204': + "204": description: Role deleted successfully - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/rbac/permissions: post: @@ -748,24 +974,24 @@ paths: type: string description: Permission action (e.g., read, write, manage) responses: - '201': + "201": description: Permission created successfully content: application/json: schema: - $ref: '#/components/schemas/Permission' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': + $ref: "#/components/schemas/Permission" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": description: Permission already exists content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" get: summary: List permissions in tenant @@ -791,18 +1017,18 @@ paths: type: integer default: 10 responses: - '200': + "200": description: List of permissions content: application/json: schema: type: array items: - $ref: '#/components/schemas/Permission' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: "#/components/schemas/Permission" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /tenant/{tenantId}/rbac/permissions/{permissionKey}: delete: @@ -825,14 +1051,14 @@ paths: type: string description: Permission key in format 'resource:action' responses: - '204': + "204": description: Permission deleted successfully - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/rbac/roles: post: @@ -865,16 +1091,16 @@ paths: type: string description: Role name responses: - '200': + "200": description: Role assigned successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" delete: summary: Remove role from account @@ -904,16 +1130,16 @@ paths: role: type: string responses: - '200': + "200": description: Role removed successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /tenant/{tenantId}/accounts/rbac/permissions: post: @@ -945,16 +1171,16 @@ paths: type: string description: Permission key in format 'resource:action' responses: - '200': + "200": description: Permission assigned successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" delete: summary: Remove permission from account @@ -984,16 +1210,16 @@ paths: permission: type: string responses: - '200': + "200": description: Permission removed successfully - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" components: securitySchemes: @@ -1009,28 +1235,28 @@ components: content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" Unauthorized: description: Authentication required or invalid token content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" Forbidden: description: Insufficient permissions (requires tenant admin or system admin role) content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" NotFound: description: Resource not found content: application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: "#/components/schemas/ProblemDetails" schemas: ProblemDetails: @@ -1114,7 +1340,7 @@ components: type: array description: Linked social providers items: - $ref: '#/components/schemas/SocialProvider' + $ref: "#/components/schemas/SocialProvider" roles: type: array description: Assigned role names @@ -1136,19 +1362,19 @@ components: AccountDetails: allOf: - - $ref: '#/components/schemas/Account' + - $ref: "#/components/schemas/Account" - type: object properties: authTokens: type: array description: Authentication tokens items: - $ref: '#/components/schemas/AuthToken' + $ref: "#/components/schemas/AuthToken" magicCodes: type: array description: Magic codes items: - $ref: '#/components/schemas/MagicCode' + $ref: "#/components/schemas/MagicCode" SocialProvider: type: object @@ -1201,6 +1427,43 @@ components: type: string format: date-time + ApiKey: + type: object + description: API key for programmatic access + properties: + id: + type: string + description: API key ID (UUID) + example: 550e8400-e29b-41d4-a716-446655440002 + tenantId: + type: string + description: Tenant ID + accountId: + type: string + description: Account ID + name: + type: string + description: API key name (unique per account per tenant) + example: my-service-key + keyPrefix: + type: string + description: Key prefix for identification + example: api_ + isEnabled: + type: boolean + description: Whether the key is active + expires: + type: string + format: date-time + nullable: true + description: Optional expiration date + created: + type: string + format: date-time + modified: + type: string + format: date-time + Role: type: object description: RBAC Role diff --git a/tests/integration/README.md b/tests/integration/README.md index 5e79320..305bd4a 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -98,6 +98,16 @@ docker-compose -f docker-compose.test.yml down -v - **DisableAccount** - Disable account - **EnableAccount** - Enable account +### API Key Tests +- **CreateApiKey** - Generate new API key, verify plaintext format +- **CreateApiKey_DuplicateName** - Duplicate name returns 409 Conflict +- **CreateApiKey_MissingAccountID** - Missing account ID rejected +- **ListApiKeys** - List all API keys for an account +- **GetApiKey** - Retrieve specific API key details +- **SuspendApiKey** - Suspend API key, verify isEnabled is false +- **EnableApiKey** - Re-enable suspended key, verify isEnabled is true +- **RevokeApiKey** - Delete API key, verify removal from list + ### RBAC Tests - **CreateRole** - Create new role with description - **ListRoles** - List all roles @@ -143,6 +153,7 @@ docker-compose -f docker-compose.test.yml down -v │ ▼ │ │ BulwarkAuthAdmin Service (localhost:8081) │ │ - Account Management │ +│ - API Key Management │ │ - RBAC Management │ │ - Tenant Management │ │ │ │ diff --git a/tests/integration/accounts/account_apikey_integration_test.go b/tests/integration/accounts/account_apikey_integration_test.go new file mode 100644 index 0000000..56d786d --- /dev/null +++ b/tests/integration/accounts/account_apikey_integration_test.go @@ -0,0 +1,256 @@ +//go:build integration + +package accounts + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/latebit-io/bulwarkauthadmin/api/accounts" + apikeyapi "github.com/latebit-io/bulwarkauthadmin/api/accounts/apikey" + "github.com/latebit-io/bulwarkauthadmin/tests/integration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestAccount registers an account and returns its ID +func createTestAccount(t *testing.T, tc *integration.TestContext) string { + email := fmt.Sprintf("apikey_test_%d@example.com", time.Now().UnixNano()) + payload := accounts.NewAccountRequest{Email: email} + + resp, err := tc.Post("/accounts", payload) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + resp.Body.Close() + + // Get account ID from list + resp, err = tc.Get("/accounts") + require.NoError(t, err) + + var accs []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&accs) + require.NoError(t, err) + resp.Body.Close() + + for _, acc := range accs { + if acc["email"] == email { + return acc["id"].(string) + } + } + + t.Fatal("Account not found after creation") + return "" +} + +func TestAccountApiKeyHandler_CreateApiKey(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("test-key-%d", time.Now().UnixNano()) + payload := apikeyapi.NewApiKeyRequest{Name: keyName} + + resp, err := tc.Post("/accounts/"+accountID+"/apikeys", payload) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var apiKey string + err = json.NewDecoder(resp.Body).Decode(&apiKey) + require.NoError(t, err) + + // Verify the key has the expected prefix + assert.Contains(t, apiKey, "api_:") +} + +func TestAccountApiKeyHandler_CreateApiKey_DuplicateName(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("duplicate-key-%d", time.Now().UnixNano()) + payload := apikeyapi.NewApiKeyRequest{Name: keyName} + + // Create first key + resp, err := tc.Post("/accounts/"+accountID+"/apikeys", payload) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Create duplicate - should fail with conflict + resp, err = tc.Post("/accounts/"+accountID+"/apikeys", payload) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusConflict, resp.StatusCode) +} + +func TestAccountApiKeyHandler_CreateApiKey_MissingAccountID(t *testing.T) { + tc := integration.NewTestContext(t) + + payload := apikeyapi.NewApiKeyRequest{Name: "some-key"} + + // POST without account ID - should get 404 or 405 since route won't match + resp, err := tc.Post("/accounts//apikeys", payload) + require.NoError(t, err) + defer resp.Body.Close() + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) +} + +// createTestApiKey creates an API key for the given account and returns the apiKeyID +func createTestApiKey(t *testing.T, tc *integration.TestContext, accountID, keyName string) string { + payload := apikeyapi.NewApiKeyRequest{Name: keyName} + + resp, err := tc.Post("/accounts/"+accountID+"/apikeys", payload) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + resp.Body.Close() + + // List keys to get the ID + resp, err = tc.Get("/accounts/" + accountID + "/apikeys") + require.NoError(t, err) + defer resp.Body.Close() + + var keys []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&keys) + require.NoError(t, err) + + for _, k := range keys { + if k["name"] == keyName { + return k["id"].(string) + } + } + + t.Fatalf("API key %q not found after creation", keyName) + return "" +} + +func TestAccountApiKeyHandler_ListApiKeys(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + // Create two keys + name1 := fmt.Sprintf("list-key-1-%d", time.Now().UnixNano()) + name2 := fmt.Sprintf("list-key-2-%d", time.Now().UnixNano()) + createTestApiKey(t, tc, accountID, name1) + createTestApiKey(t, tc, accountID, name2) + + resp, err := tc.Get("/accounts/" + accountID + "/apikeys") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var keys []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&keys) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(keys), 2) + + // Verify both names are present + names := make(map[string]bool) + for _, k := range keys { + names[k["name"].(string)] = true + } + assert.True(t, names[name1], "expected key %q in list", name1) + assert.True(t, names[name2], "expected key %q in list", name2) +} + +func TestAccountApiKeyHandler_GetApiKey(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("get-key-%d", time.Now().UnixNano()) + apiKeyID := createTestApiKey(t, tc, accountID, keyName) + + resp, err := tc.Get("/accounts/" + accountID + "/apikeys/" + apiKeyID) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var key map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&key) + require.NoError(t, err) + assert.Equal(t, keyName, key["name"]) + assert.Equal(t, apiKeyID, key["id"]) + assert.Equal(t, accountID, key["accountId"]) +} + +func TestAccountApiKeyHandler_SuspendApiKey(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("suspend-key-%d", time.Now().UnixNano()) + apiKeyID := createTestApiKey(t, tc, accountID, keyName) + + // Suspend the key + resp, err := tc.Put("/accounts/"+accountID+"/apikeys/"+apiKeyID+"/suspend", nil) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + resp.Body.Close() + + // Verify it's disabled + resp, err = tc.Get("/accounts/" + accountID + "/apikeys/" + apiKeyID) + require.NoError(t, err) + defer resp.Body.Close() + + var key map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&key) + require.NoError(t, err) + assert.Equal(t, false, key["isEnabled"]) +} + +func TestAccountApiKeyHandler_EnableApiKey(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("enable-key-%d", time.Now().UnixNano()) + apiKeyID := createTestApiKey(t, tc, accountID, keyName) + + // Suspend first + resp, err := tc.Put("/accounts/"+accountID+"/apikeys/"+apiKeyID+"/suspend", nil) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Enable the key + resp, err = tc.Put("/accounts/"+accountID+"/apikeys/"+apiKeyID+"/enable", nil) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + resp.Body.Close() + + // Verify it's enabled + resp, err = tc.Get("/accounts/" + accountID + "/apikeys/" + apiKeyID) + require.NoError(t, err) + defer resp.Body.Close() + + var key map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&key) + require.NoError(t, err) + assert.Equal(t, true, key["isEnabled"]) +} + +func TestAccountApiKeyHandler_RevokeApiKey(t *testing.T) { + tc := integration.NewTestContext(t) + accountID := createTestAccount(t, tc) + + keyName := fmt.Sprintf("revoke-key-%d", time.Now().UnixNano()) + apiKeyID := createTestApiKey(t, tc, accountID, keyName) + + // Revoke the key + resp, err := tc.Delete("/accounts/" + accountID + "/apikeys/" + apiKeyID) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + resp.Body.Close() + + // Verify it's gone - listing should not contain the revoked key + resp, err = tc.Get("/accounts/" + accountID + "/apikeys") + require.NoError(t, err) + defer resp.Body.Close() + + var keys []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&keys) + require.NoError(t, err) + + for _, k := range keys { + assert.NotEqual(t, apiKeyID, k["id"], "revoked key should not appear in list") + } +}