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
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
"Bash(do echo:*)",
"Read(//Users/lakhansamani/personal/authorizer/authorizer/internal/storage/db/**)",
"Bash(done)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/integration_tests/)",
"Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/memory_store/...)",
"Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/storage/)"
]
}
}
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: "trivy-results.sarif"
17 changes: 10 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ make build-app # Build login UI (web/app)
make build-dashboard # Build admin UI (web/dashboard)
make generate-graphql # Regenerate after schema.graphqls change

# Testing (TEST_DBS env var selects databases, default: postgres)
make test # Docker Postgres (default)
make test-sqlite # SQLite in-memory (no Docker)
make test-mongodb # Docker MongoDB
# Testing
# Integration tests always use SQLite (no Docker needed).
# Storage provider tests honour TEST_DBS (default: all 7 DBs, needs Docker).
# Optional: TEST_ENABLE_REDIS=1 runs Redis memory_store unit tests (Redis on localhost:6380).
make test # SQLite integration + storage (via TEST_DBS)
make test-sqlite # SQLite everywhere (no Docker)
make test-all-db # ALL 7 databases (postgres,sqlite,mongodb,arangodb,scylladb,dynamodb,couchbase)

# Single test against specific DBs
go clean --testcache && TEST_DBS="sqlite,postgres" go test -p 1 -v -run TestSignup ./internal/integration_tests/
# Single test
go clean --testcache && TEST_DBS="sqlite" go test -p 1 -v -run TestSignup ./internal/integration_tests/
```

## Architecture (Quick Reference)
Expand All @@ -40,6 +42,7 @@ go clean --testcache && TEST_DBS="sqlite,postgres" go test -p 1 -v -run TestSign
- Token management: `internal/token/`
- Tests: `internal/integration_tests/`
- Frontend: `web/app/` (user UI) | `web/dashboard/` (admin UI)
- Optional NULL semantics across SQL/document DBs and DynamoDB: `docs/storage-optional-null-fields.md`

**Pattern**: Every subsystem uses `Dependencies` struct + `New()` → `Provider` interface.

Expand All @@ -49,7 +52,7 @@ go clean --testcache && TEST_DBS="sqlite,postgres" go test -p 1 -v -run TestSign
2. **Schema changes must update ALL 13+ database providers**
3. **Run `make generate-graphql`** after editing `schema.graphqls`
4. **Security**: parameterized queries only, `crypto/rand` for tokens, `crypto/subtle` for comparisons, never log secrets
5. **Tests**: integration tests with real DBs, table-driven subtests, testify assertions
5. **Tests**: integration tests use SQLite via `getTestConfig()` (no `runForEachDB`); storage tests cover all DBs via `TEST_DBS`; testify assertions
6. **NEVER commit to main** — always work on a feature branch (`feat/`, `fix/`, `security/`, `chore/`), push to the branch, and create a merge request. Main must stay deployable.

## AI Agent Roles
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# syntax=docker/dockerfile:1.4
# Use BuildKit for cache mounts (faster CI: DOCKER_BUILDKIT=1)
#
# Alpine v3.23 main still ships busybox 1.37.0-r30 (e.g. CVE-2025-60876); edge/main has r31+.
# Pin busybox from edge until the stable branch backports it. See alpine/aports work item #17940.
FROM golang:1.25-alpine3.23 AS go-builder
ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main
RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31"
WORKDIR /authorizer

ARG TARGETPLATFORM
Expand Down Expand Up @@ -33,6 +38,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \
chmod 755 build/${GOOS}/${GOARCH}/authorizer

FROM alpine:3.23.3 AS node-builder
ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main
RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31"
WORKDIR /authorizer
COPY web/app/package*.json web/app/
COPY web/dashboard/package*.json web/dashboard/
Expand All @@ -47,6 +54,8 @@ COPY web/dashboard web/dashboard
RUN cd web/app && npm run build && cd ../dashboard && npm run build

FROM alpine:3.23.3
ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main
RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31"

ARG TARGETARCH=amd64

Expand Down
28 changes: 16 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ DEFAULT_VERSION=0.1.0-local
VERSION := $(or $(VERSION),$(DEFAULT_VERSION))
DOCKER_IMAGE ?= authorizerdev/authorizer:$(VERSION)

# Full module test run. Storage provider tests honour TEST_DBS (defaults to all).
# Integration tests and memory_store/db tests always use SQLite.
# Redis memory_store tests run only when TEST_ENABLE_REDIS=1.
GO_TEST_ALL := go test -p 1 -v ./...

.PHONY: all bootstrap build build-app build-dashboard build-local-image build-push-image trivy-scan

all: build build-app build-dashboard
Expand Down Expand Up @@ -39,51 +44,50 @@ clean:
dev:
go run main.go --database-type=sqlite --database-url=test.db --jwt-type=HS256 --jwt-secret=test --admin-secret=admin --client-id=123456 --client-secret=secret

test: test-cleanup test-docker-up
go clean --testcache && TEST_DBS="postgres" go test -p 1 -v ./...
$(MAKE) test-cleanup
test:
go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL)

test-postgres: test-cleanup-postgres
docker run -d --name authorizer_postgres -p 5434:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres postgres
sleep 3
go clean --testcache && TEST_DBS="postgres" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="postgres" $(GO_TEST_ALL)
docker rm -vf authorizer_postgres

test-sqlite:
go clean --testcache && TEST_DBS="sqlite" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL)

test-mongodb: test-cleanup-mongodb
docker run -d --name authorizer_mongodb_db -p 27017:27017 mongo:4.4.15
sleep 3
go clean --testcache && TEST_DBS="mongodb" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="mongodb" $(GO_TEST_ALL)
docker rm -vf authorizer_mongodb_db

test-scylladb: test-cleanup-scylladb
docker run -d --name authorizer_scylla_db -p 9042:9042 scylladb/scylla
sleep 15
go clean --testcache && TEST_DBS="scylladb" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="scylladb" $(GO_TEST_ALL)
docker rm -vf authorizer_scylla_db

test-arangodb: test-cleanup-arangodb
docker run -d --name authorizer_arangodb -p 8529:8529 -e ARANGO_NO_AUTH=1 arangodb/arangodb:3.10.3
sleep 5
go clean --testcache && TEST_DBS="arangodb" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="arangodb" $(GO_TEST_ALL)
docker rm -vf authorizer_arangodb

test-dynamodb: test-cleanup-dynamodb
docker run -d --name authorizer_dynamodb -p 8000:8000 amazon/dynamodb-local:latest
sleep 3
go clean --testcache && TEST_DBS="dynamodb" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="dynamodb" $(GO_TEST_ALL)
docker rm -vf authorizer_dynamodb

test-couchbase: test-cleanup-couchbase
docker run -d --name authorizer_couchbase -p 8091-8097:8091-8097 -p 11210:11210 -p 11207:11207 -p 18091-18095:18091-18095 -p 18096:18096 -p 18097:18097 couchbase:latest
sh scripts/couchbase-test.sh
go clean --testcache && TEST_DBS="couchbase" go test -p 1 -v ./...
go clean --testcache && TEST_DBS="couchbase" $(GO_TEST_ALL)
docker rm -vf authorizer_couchbase

test-all-db: test-cleanup test-docker-up
go clean --testcache && TEST_DBS="postgres,sqlite,mongodb,arangodb,scylladb,dynamodb,couchbase" go test -p 1 -v ./...
test-all-db: test-cleanup test-docker-up test-cleanup
go clean --testcache && TEST_DBS="postgres,sqlite,mongodb,arangodb,scylladb,dynamodb,couchbase" $(GO_TEST_ALL)
$(MAKE) test-cleanup

# Start all test database containers
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ var (
defaultTwitterScopes = []string{"tweet.read", "users.read"}
defaultRobloxScopes = []string{"openid", "profile"}
// Default RPS cap per IP; raised from 10 to reduce false positives on busy UIs.
defaultRateLimitRPS = float64(30)
defaultRateLimitRPS = 30
defaultRateLimitBurst = 20
)

Expand Down Expand Up @@ -161,7 +161,7 @@ func init() {
f.BoolVar(&rootArgs.config.DisableAdminHeaderAuth, "disable-admin-header-auth", false, "Disable admin authentication via X-Authorizer-Admin-Secret header")

// Rate limiting flags
f.Float64Var(&rootArgs.config.RateLimitRPS, "rate-limit-rps", defaultRateLimitRPS, "Maximum requests per second per IP for rate limiting")
f.IntVar(&rootArgs.config.RateLimitRPS, "rate-limit-rps", defaultRateLimitRPS, "Maximum requests per second per IP for rate limiting")
f.IntVar(&rootArgs.config.RateLimitBurst, "rate-limit-burst", defaultRateLimitBurst, "Maximum burst size per IP for rate limiting")
f.BoolVar(&rootArgs.config.RateLimitFailClosed, "rate-limit-fail-closed", false, "On rate-limit backend errors, reject with 503 instead of allowing the request")

Expand Down
46 changes: 46 additions & 0 deletions docs/storage-optional-null-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Optional fields and NULL semantics across storage providers

This document explains how **nullable** fields (especially `*int64` timestamps such as `email_verified_at`, `phone_number_verified_at`, `revoked_timestamp`) are **stored**, **updated**, and why we **do not** add broad `json`/`bson` **`omitempty`** tags to those struct fields.

## Goal

Application code uses Go **nil pointers** to mean “unset / not verified / not revoked.” Updates must **clear** a previously set value when the pointer is **nil**, matching SQL **NULL** semantics.

## Behaviour by provider

| Provider | Typical update path | Nil pointer on update |
|----------|---------------------|------------------------|
| **SQL (GORM)** | `Save(&user)` | Written as **SQL NULL**. |
| **Cassandra** | JSON → map → `UPDATE` | Nil map values become **`= null`** in CQL. |
| **MongoDB** | `UpdateOne` with `$set` and the `User` struct | Driver marshals nil pointers as **BSON Null** when the field is **not** `omitempty`, so the field is cleared in the document. |
| **Couchbase** | `Upsert` full document | `encoding/json` encodes nil pointers as JSON **`null`** unless the field uses `json:",omitempty"`, in which case the key is **omitted** and old values can persist. |
| **ArangoDB** | `UpdateDocument` with struct | Encoding follows JSON-style rules; nil pointers become **`null`** when not omitted by tags. |
| **DynamoDB** | `UpdateItem` with **SET** from marshalled attributes | Nil pointers are **omitted from SET** (see `internal/storage/db/dynamodb/marshal.go`). Attributes are **not** removed automatically, so **explicit REMOVE** is required to clear a previously stored attribute. Implemented for users in `internal/storage/db/dynamodb/user.go` (`updateByHashKeyWithRemoves`, `userDynamoRemoveAttrsIfNil`). Reads may normalize `0` → unset via `normalizeUserOptionalPtrs`. |

## Why not use `omitempty` on `json` / `bson` for nullable auth fields?

For **document** databases, **`omitempty`** means: *if this pointer is nil, do not include this key in the encoded payload.*

During an **update**, omitting a key usually means **“do not change this field”**, not **“set to null.”** That reproduces the DynamoDB-class bug: the old value remains.

Therefore, for fields where **nil must clear** stored state, keep **`json` / `bson` tags without `omitempty`** (as in `internal/storage/schemas/user.go`) unless every call site is proven to do a **full document replace** and you have verified the driver behaviour end-to-end.

MongoDB’s own guidance aligns with this: `omitempty` skips marshaling empty values, which is wrong when you need to persist **null** to clear a field in `$set`.

## DynamoDB specifics

- **PutItem**: Omitting nil pointers keeps items small; optional attributes may be absent (same idea as “omitempty” on write, but implemented in custom `marshalStruct` by skipping nil pointers).
- **UpdateItem**: Only **SET** attributes present in the marshalled map. Clearing requires **`REMOVE`** for the corresponding attribute names when the Go field is nil.
- Do **not** rely on adding `dynamo:",omitempty"` alone to “fix” updates: the custom marshaller already skips nil pointers; the gap was **REMOVE** on update, not tag-based omission.

## Related code

- Schema: `internal/storage/schemas/user.go`
- DynamoDB user update + REMOVE list: `internal/storage/db/dynamodb/user.go`
- DynamoDB update helper: `internal/storage/db/dynamodb/ops.go` (`updateByHashKeyWithRemoves`)
- DynamoDB marshal/unmarshal: `internal/storage/db/dynamodb/marshal.go`

## TEST_DBS and memory_store tests

- **`internal/memory_store/db`**: Runs one subtest per entry in `TEST_DBS` (URLs aligned with `internal/integration_tests/test_helper.go` — keep `test_config_test.go` in sync when adding backends).
- **`internal/memory_store` (Redis / in-memory)**: Not driven by `TEST_DBS`. In-memory tests always run; **Redis** subtests run only when **`TEST_ENABLE_REDIS=1`** (or `true`) and Redis is reachable (e.g. `localhost:6380` in `provider_test.go`). See `redisMemoryStoreTestsEnabled` in `provider_test.go`.
28 changes: 21 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ go 1.25.5
require (
github.com/99designs/gqlgen v0.17.73
github.com/arangodb/go-driver v1.6.0
github.com/aws/aws-sdk-go v1.47.4
github.com/coreos/go-oidc/v3 v3.6.0
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.37
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.8.37
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.1
github.com/aws/smithy-go v1.24.3
github.com/coreos/go-oidc/v3 v3.17.0
github.com/couchbase/gocb/v2 v2.6.4
github.com/ekristen/gorm-libsql v0.0.0-20231101204708-6e113112bcc2
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.10.0
github.com/go-jose/go-jose/v4 v4.1.3
github.com/go-jose/go-jose/v4 v4.1.4
github.com/gocql/gocql v1.6.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/guregu/dynamo v1.20.2
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.6.3
Expand All @@ -41,10 +46,21 @@ require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/couchbase/gocbcore/v10 v10.2.8 // indirect
Expand All @@ -55,7 +71,6 @@ require (
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
Expand All @@ -77,7 +92,6 @@ require (
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
Expand Down
Loading