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
20 changes: 16 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,24 @@ Please ask as many questions as you need, either directly in the issue or on [Di

### Adding support for new database

- Run `make generate-db-template dbname=NEW_DB_NAME`
- e.g. `make generate-db-template dbname=dynamodb`
1. Run `make generate-db-template dbname=NEW_DB_NAME`
- e.g. `make generate-db-template dbname=dynamodb`

This generates a folder in `internal/storage/db/` with the specified name. Implement the methods in that folder.
This copies `internal/storage/db/provider_template/` to `internal/storage/db/NEW_DB_NAME/` and renames the package. The template already stubs every method of `storage.Provider` (`internal/storage/provider.go`) across all feature areas — users, sessions, webhooks, email templates, OTP, authenticators, memory-store (session/MFA/OAuth-state), audit logs, clients, trusted issuers, SAML (SP + IDP keys), SCIM (endpoints + groups), WebAuthn credentials, organizations, org memberships, org domains, and federated identities. Run `go test ./internal/storage/db/NEW_DB_NAME/...` any time to confirm it still satisfies `storage.Provider` in full — `interface_test.go` fails to compile the instant a method goes missing.

> Note: Database connection and schema changes are in `internal/storage/db/DB_NAME/provider.go`; `NewProvider` is called for the configured database type.
2. Change the `provider` struct and `NewProvider` in `NEW_DB_NAME/provider.go` to hold and construct your actual database client (the template ships with a placeholder `*gorm.DB` field — replace it).

3. Implement each stubbed method for real, one feature file at a time. Use an existing provider as a reference for the query patterns of a similar backend:
- SQL-like/GORM backend → `internal/storage/db/sql/`
- Document store → `internal/storage/db/mongodb/` or `internal/storage/db/arangodb/`
- Wide-column store → `internal/storage/db/cassandradb/`
- Key-value store → `internal/storage/db/dynamodb/` or `internal/storage/db/couchbase/`

4. Wire the new provider into `storage.New()` (`internal/storage/provider.go`) behind its config-selected database type.

5. Add the new provider to the storage test matrix (`TEST_DBS`) and a `make test-NEW_DB_NAME` / `test-cleanup-NEW_DB_NAME` Docker target in the `Makefile`, following the pattern of the existing `test-postgres` / `test-mongodb` targets.

> Note: `go test ./internal/storage/db/NEW_DB_NAME/...` will fail to compile with a `does not implement storage.Provider (missing method ...)` error until every method is implemented. This check lives in a `_test` file rather than in `provider.go` itself — `internal/storage` imports every concrete provider (including yours, once step 4 is done), so a same-package assertion would create an import cycle.

### Testing

Expand Down
28 changes: 28 additions & 0 deletions internal/storage/db/provider_template/audit_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package provider_template

import (
"context"

"github.com/google/uuid"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddAuditLog adds an audit log entry
func (p *provider) AddAuditLog(ctx context.Context, log *schemas.AuditLog) error {
if log.ID == "" {
log.ID = uuid.New().String()
}
return nil
}

// ListAuditLogs queries audit logs with filters and pagination
func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) {
return nil, nil, nil
}

// DeleteAuditLogsBefore removes logs older than a timestamp (retention)
func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error {
return nil
}
48 changes: 48 additions & 0 deletions internal/storage/db/provider_template/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package provider_template

import (
"context"
"time"

"github.com/google/uuid"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddClient creates a new service account record.
func (p *provider) AddClient(ctx context.Context, sa *schemas.Client) (*schemas.Client, error) {
if sa.ID == "" {
sa.ID = uuid.New().String()
}
sa.CreatedAt = time.Now().Unix()
sa.UpdatedAt = time.Now().Unix()
return sa, nil
}

// UpdateClient updates name, description, allowed_scopes, or is_active.
func (p *provider) UpdateClient(ctx context.Context, sa *schemas.Client) (*schemas.Client, error) {
sa.UpdatedAt = time.Now().Unix()
return sa, nil
}

// DeleteClient removes a client. Callers must delete associated TrustedIssuers
// before or within the same logical operation.
func (p *provider) DeleteClient(ctx context.Context, sa *schemas.Client) error {
return nil
}

// GetClientByID fetches a client by its surrogate primary key.
func (p *provider) GetClientByID(ctx context.Context, id string) (*schemas.Client, error) {
return nil, nil
}

// GetClientByClientID fetches a client by its public, unique client_id.
func (p *provider) GetClientByClientID(ctx context.Context, clientID string) (*schemas.Client, error) {
return nil, nil
}

// ListClients returns a paginated list of all clients.
func (p *provider) ListClients(ctx context.Context, pagination *model.Pagination) ([]*schemas.Client, *model.Pagination, error) {
return nil, nil, nil
}
27 changes: 27 additions & 0 deletions internal/storage/db/provider_template/federated_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package provider_template

import (
"context"
"time"

"github.com/google/uuid"

"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddFederatedIdentity records a JIT-provisioned upstream identity. The
// (org_id, issuer, subject) triple is unique — adding a duplicate returns an
// error.
func (p *provider) AddFederatedIdentity(ctx context.Context, identity *schemas.FederatedIdentity) (*schemas.FederatedIdentity, error) {
if identity.ID == "" {
identity.ID = uuid.New().String()
}
identity.CreatedAt = time.Now().Unix()
identity.UpdatedAt = time.Now().Unix()
return identity, nil
}

// GetFederatedIdentity fetches the identity for a (orgID, issuer, subject) triple.
func (p *provider) GetFederatedIdentity(ctx context.Context, orgID, issuer, subject string) (*schemas.FederatedIdentity, error) {
return nil, nil
}
10 changes: 10 additions & 0 deletions internal/storage/db/provider_template/health_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package provider_template

import (
"context"
)

// HealthCheck verifies that the storage backend is reachable and responsive.
func (p *provider) HealthCheck(ctx context.Context) error {
return nil
}
25 changes: 25 additions & 0 deletions internal/storage/db/provider_template/interface_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package provider_template_test

import (
"testing"

"github.com/rs/zerolog"

"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/storage"
"github.com/authorizerdev/authorizer/internal/storage/db/provider_template"
)

// TestImplementsStorageProvider fails to compile — not just to run — the
// moment provider stops satisfying storage.Provider. It lives in the
// _test external package so it can import internal/storage without an
// import cycle (internal/storage will import this package's non-test code
// once it's wired into storage.New(); see provider.go).
func TestImplementsStorageProvider(t *testing.T) {
log := zerolog.Nop()
p, err := provider_template.NewProvider(&config.Config{}, &provider_template.Dependencies{Log: &log})
if err != nil {
t.Fatalf("NewProvider() error = %v", err)
}
var _ storage.Provider = p
}
44 changes: 44 additions & 0 deletions internal/storage/db/provider_template/org_domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package provider_template

import (
"context"
"time"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddOrgDomain atomically inserts a verified domain row, keyed by the
// normalized domain. Caller MUST set ID and Domain to the normalized domain
// before calling. First-writer-wins: same org already holding the domain is
// idempotent success, a different org owning it returns schemas.ErrOrgDomainConflict.
func (p *provider) AddOrgDomain(ctx context.Context, domain *schemas.OrgDomain) (*schemas.OrgDomain, error) {
now := time.Now().Unix()
domain.CreatedAt = now
domain.UpdatedAt = now
if domain.VerifiedAt == 0 {
domain.VerifiedAt = now
}
return domain, nil
}

// GetOrgDomainByDomain fetches the verified row for a normalized domain.
func (p *provider) GetOrgDomainByDomain(ctx context.Context, domain string) (*schemas.OrgDomain, error) {
return nil, nil
}

// ListOrgDomainsByOrg returns an org's verified domains, paginated.
func (p *provider) ListOrgDomainsByOrg(ctx context.Context, orgID string, pagination *model.Pagination) ([]*schemas.OrgDomain, *model.Pagination, error) {
return nil, nil, nil
}

// DeleteOrgDomain removes a verified domain mapping by normalized domain.
func (p *provider) DeleteOrgDomain(ctx context.Context, domain string) error {
return nil
}

// DeleteOrgDomainsByOrg removes all of an org's verified domains (cascade on
// org delete — otherwise the domain becomes permanently unclaimable).
func (p *provider) DeleteOrgDomainsByOrg(ctx context.Context, orgID string) error {
return nil
}
48 changes: 48 additions & 0 deletions internal/storage/db/provider_template/org_membership.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package provider_template

import (
"context"
"time"

"github.com/google/uuid"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddOrgMembership creates a new membership. The (org_id, user_id) pair is
// unique — adding a duplicate returns an error.
func (p *provider) AddOrgMembership(ctx context.Context, membership *schemas.OrgMembership) (*schemas.OrgMembership, error) {
if membership.ID == "" {
membership.ID = uuid.New().String()
}
membership.CreatedAt = time.Now().Unix()
membership.UpdatedAt = time.Now().Unix()
return membership, nil
}

// GetOrgMembership fetches the membership for a (orgID, userID) pair.
func (p *provider) GetOrgMembership(ctx context.Context, orgID, userID string) (*schemas.OrgMembership, error) {
return nil, nil
}

// UpdateOrgMembership updates the roles of an existing membership.
func (p *provider) UpdateOrgMembership(ctx context.Context, membership *schemas.OrgMembership) (*schemas.OrgMembership, error) {
membership.UpdatedAt = time.Now().Unix()
return membership, nil
}

// DeleteOrgMembership removes a membership.
func (p *provider) DeleteOrgMembership(ctx context.Context, membership *schemas.OrgMembership) error {
return nil
}

// ListOrgMembershipsByOrg returns paginated memberships of an organization.
func (p *provider) ListOrgMembershipsByOrg(ctx context.Context, orgID string, pagination *model.Pagination) ([]*schemas.OrgMembership, *model.Pagination, error) {
return nil, nil, nil
}

// ListOrgMembershipsByUser returns paginated memberships held by a user.
func (p *provider) ListOrgMembershipsByUser(ctx context.Context, userID string, pagination *model.Pagination) ([]*schemas.OrgMembership, *model.Pagination, error) {
return nil, nil, nil
}
47 changes: 47 additions & 0 deletions internal/storage/db/provider_template/organization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package provider_template

import (
"context"
"time"

"github.com/google/uuid"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// AddOrganization creates a new organization record.
func (p *provider) AddOrganization(ctx context.Context, org *schemas.Organization) (*schemas.Organization, error) {
if org.ID == "" {
org.ID = uuid.New().String()
}
org.CreatedAt = time.Now().Unix()
org.UpdatedAt = time.Now().Unix()
return org, nil
}

// GetOrganizationByID fetches an organization by its primary key.
func (p *provider) GetOrganizationByID(ctx context.Context, id string) (*schemas.Organization, error) {
return nil, nil
}

// GetOrganizationByName fetches an organization by its unique name slug.
func (p *provider) GetOrganizationByName(ctx context.Context, name string) (*schemas.Organization, error) {
return nil, nil
}

// UpdateOrganization updates name, display_name, or enabled.
func (p *provider) UpdateOrganization(ctx context.Context, org *schemas.Organization) (*schemas.Organization, error) {
org.UpdatedAt = time.Now().Unix()
return org, nil
}

// DeleteOrganization removes an organization and cascade-deletes its memberships.
func (p *provider) DeleteOrganization(ctx context.Context, org *schemas.Organization) error {
return nil
}

// ListOrganizations returns a paginated list of all organizations.
func (p *provider) ListOrganizations(ctx context.Context, pagination *model.Pagination) ([]*schemas.Organization, *model.Pagination, error) {
return nil, nil, nil
}
25 changes: 13 additions & 12 deletions internal/storage/db/provider_template/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,21 @@ type provider struct {
}

// NewProvider returns a new provider for your database type.
// TODO: change provider struct and NewProvider to use your database client.
//
// This provider must implement all methods from storage.Provider, including:
// - User, VerificationRequest, Session, Webhook, EmailTemplate, OTP, Authenticator
// - Memory store methods (when Redis is not configured):
// - SessionToken: AddSessionToken, GetSessionTokenByUserIDAndKey, DeleteSessionToken,
// DeleteSessionTokenByUserIDAndKey, DeleteAllSessionTokensByUserID,
// DeleteSessionTokensByNamespace, CleanExpiredSessionTokens, GetAllSessionTokens
// - MFASession: AddMFASession, GetMFASessionByUserIDAndKey, DeleteMFASession,
// DeleteMFASessionByUserIDAndKey, GetAllMFASessionsByUserID,
// CleanExpiredMFASessions, GetAllMFASessions
// - OAuthState: AddOAuthState, GetOAuthStateByKey, DeleteOAuthStateByKey, GetAllOAuthStates
// The parent internal/storage package cannot be imported here to add a
// `var _ storage.Provider = (*provider)(nil)` assertion: internal/storage
// imports every concrete provider package (including this one, once you wire
// it into storage.New()), so importing it back would create an import cycle.
// See interface_test.go for the equivalent check done from an external test
// package instead — run `go test ./internal/storage/db/provider_template/...`
// (or `go build ./...` after wiring into storage.New()) to verify parity with
// storage.Provider.
// TODO: change provider struct and NewProvider to use your database client.
//
// Use schemas.Collections for table/collection names (e.g., schemas.Collections.SessionToken).
// This provider must implement every method of storage.Provider — see that
// interface (internal/storage/provider.go) for the authoritative, documented
// list. Use schemas.Collections for table/collection names (e.g.,
// schemas.Collections.SessionToken).
func NewProvider(
config *config.Config,
deps *Dependencies,
Expand Down
Loading
Loading