Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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_:<uuid>"`
- 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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -309,13 +319,15 @@ 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
tenants/ # Tenant management endpoints

internal/ # Business logic and data access
accounts/ # Account domain
apikey/ # API key domain
rbac/ # RBAC domain
tenants/ # Tenant domain
email/ # Email template management
Expand Down
179 changes: 179 additions & 0 deletions api/accounts/apikey/accounts_handler.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
ontehfritz marked this conversation as resolved.
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)
}
12 changes: 12 additions & 0 deletions api/accounts/apikey/accounts_routes.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions cmd/bulwarkauthadmin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@ 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"
rbacapi "github.com/latebit-io/bulwarkauthadmin/api/rbac"
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"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading