diff --git a/README.md b/README.md
index 315fc17..41278f2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@
- [Portal App Store Refresh](#portal-app-store-refresh)
- [How does Portal App Store Refresh Work?](#how-does-portal-app-store-refresh-work)
- [Configuration](#configuration)
+- [PostgREST Data Source](#postgrest-data-source)
+ - [PostgREST Configuration](#postgrest-configuration)
+ - [JWT Authentication](#jwt-authentication)
- [Envoy Gateway Integration](#envoy-gateway-integration)
- [Prometheus Metrics](#prometheus-metrics)
- [Key Metrics](#key-metrics)
@@ -120,8 +123,8 @@ PEAS adds the following headers to authorized requests before forwarding them to
| Header | Contents | Included For All Requests | Example Value |
| ----------------------- | ---------------------------------------------- | ------------------------- | ------------- |
-| `Portal-Application-ID` | The portal app ID of the authorized portal app | ✅ | "a12b3c4d" |
-| `Portal-Account-ID` | The account ID associated with the portal app | ✅ | "3f4g2js2" |
+| `Portal-Application-ID` | The portal app ID of the authorized portal app | ✅ | "a12b3c4d" |
+| `Portal-Account-ID` | The account ID associated with the portal app | ✅ | "3f4g2js2" |
## Rate Limiting Implementation
@@ -164,6 +167,44 @@ The refresh interval is configurable via the `REFRESH_INTERVAL` environment vari
- **Format**: Duration string (e.g., `30s`, `1m`, `2m30s`)
- **Purpose**: Balance between data freshness and database load
+## PostgREST Data Source
+
+PEAS supports [PostgREST](https://docs.postgrest.org/en/v13/) as an alternative data source to direct PostgreSQL connections. PostgREST provides a RESTful API layer over PostgreSQL databases, enabling PEAS to fetch portal application and account data via HTTP rather than direct database connections.
+
+For more information about PostgREST configuration and usage, see the [official PostgREST documentation](https://docs.postgrest.org/en/v13/).
+
+
+
+### PostgREST Configuration
+
+To use PostgREST as the data source, configure the following environment variables:
+
+```bash
+# Set data source type to PostgREST
+DATA_SOURCE_TYPE="postgrest"
+
+# PostgREST API endpoint
+POSTGREST_BASE_URL="https://db.rpc.com/api"
+
+# JWT authentication (required for PostgREST access)
+POSTGREST_JWT_SECRET="supersecretjwtsecretforlocaldevelopment123456789"
+POSTGREST_JWT_EMAIL="service@rpc.com"
+POSTGREST_JWT_ROLE="admin"
+
+# Request timeout (optional)
+POSTGREST_TIMEOUT="30s" # Optional, defaults to 30s
+```
+
+### JWT Authentication
+
+PEAS generates fresh JWT tokens for each PostgREST API request using the configured secret, email, and role.
+
+This ensures secure access to the PostgREST API with proper authentication and authorization based on your [PostgREST JWT configuration](https://docs.postgrest.org/en/v13/references/auth.html).
+
## Envoy Gateway Integration
PEAS exposes a gRPC service that adheres to the spec expected by Envoy Proxy's `ext_authz` HTTP Filter.
@@ -285,17 +326,27 @@ This tool uses gRPC reflection to communicate with PEAS, testing the same author
PEAS is configured via environment variables.
-| Variable | Required | Type | Description | Example | Default Value |
-| --------------------------------- | -------- | -------- | ------------------------------------------------------------ | ---------------------------------------------------- | ------------- |
-| POSTGRES_CONNECTION_STRING | ✅ | string | PostgreSQL connection string for the PortalApp database | postgresql://username:password@localhost:5432/dbname | - |
-| GCP_PROJECT_ID | ✅ | string | GCP project ID for the data warehouse used by rate limiting | your-project-id | - |
-| PORT | ❌ | int | Port to run the external auth server on | 10001 | 10001 |
-| METRICS_PORT | ❌ | int | Port to run the Prometheus metrics server on | 9090 | 9090 |
-| PPROF_PORT | ❌ | int | Port to run the pprof server on | 6060 | 6060 |
-| LOGGER_LEVEL | ❌ | string | Log level for the external auth server | info, debug, warn, error | info |
-| IMAGE_TAG | ❌ | string | Image tag/version for the application | v1.0.0 | development |
-| PORTAL_APP_STORE_REFRESH_INTERVAL | ❌ | duration | Refresh interval for portal app data from the database | 30s, 1m, 2m30s | 30s |
-| RATE_LIMIT_STORE_REFRESH_INTERVAL | ❌ | duration | Refresh interval for rate limit data from the data warehouse | 30s, 1m, 2m30s | 5m |
+| Variable | Required | Type | Description | Example | Default Value |
+| --------------------------------- | -------- | -------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------- |
+| **Data Source Configuration** | | | | | |
+| DATA_SOURCE_TYPE | ❌ | string | Data source type: "postgres" or "postgrest" | postgres, postgrest | postgres |
+| POSTGRES_CONNECTION_STRING | ✅* | string | PostgreSQL connection string (required when DATA_SOURCE_TYPE=postgres) | postgresql://username:password@localhost:5432/dbname | - |
+| POSTGREST_BASE_URL | ✅* | string | PostgREST API base URL (required when DATA_SOURCE_TYPE=postgrest) | https://db.rpc.com/api | - |
+| POSTGREST_JWT_SECRET | ✅* | string | JWT secret for PostgREST authentication (required when DATA_SOURCE_TYPE=postgrest) | supersecretjwtsecretforlocaldevelopment123456789 | - |
+| POSTGREST_JWT_EMAIL | ✅* | string | JWT email for PostgREST authentication (required when DATA_SOURCE_TYPE=postgrest) | service@rpc.com | - |
+| POSTGREST_JWT_ROLE | ❌ | string | JWT role for PostgREST authentication | admin | - |
+| POSTGREST_TIMEOUT | ❌ | duration | PostgREST request timeout | 30s, 1m, 2m30s | 30s |
+| **System Configuration** | | | | | |
+| GCP_PROJECT_ID | ✅ | string | GCP project ID for the data warehouse used by rate limiting | your-project-id | - |
+| PORT | ❌ | int | Port to run the external auth server on | 10001 | 10001 |
+| METRICS_PORT | ❌ | int | Port to run the Prometheus metrics server on | 9090 | 9090 |
+| PPROF_PORT | ❌ | int | Port to run the pprof server on | 6060 | 6060 |
+| LOGGER_LEVEL | ❌ | string | Log level for the external auth server | info, debug, warn, error | info |
+| IMAGE_TAG | ❌ | string | Image tag/version for the application | v1.0.0 | development |
+| PORTAL_APP_STORE_REFRESH_INTERVAL | ❌ | duration | Refresh interval for portal app data from the database | 30s, 1m, 2m30s | 30s |
+| RATE_LIMIT_STORE_REFRESH_INTERVAL | ❌ | duration | Refresh interval for rate limit data from the data warehouse | 30s, 1m, 2m30s | 5m |
+
+**\* Required when the corresponding DATA_SOURCE_TYPE is selected**
## Developing Metrics Dashboard Locally
@@ -329,9 +380,9 @@ This section describes how to run and test the PEAS metrics dashboard locally us
- **PEAS Health**: `http://localhost:9090/healthz`
- **PEAS pprof**: `http://localhost:6060/debug/pprof/`
- **Prometheus**: `http://localhost:9091`
- - **Grafana**: `http://localhost:3000` (admin/admin)
+ - **Grafana**: `https://db.rpc.com/api` (admin/admin)
4. **View the dashboard**:
- - Go to Grafana at `http://localhost:3000`
+ - Go to Grafana at `https://db.rpc.com/api`
- Login with admin/admin
- The PEAS dashboard should be automatically loaded
diff --git a/env.example b/env.example
index 2bbdd0d..0bba39d 100644
--- a/env.example
+++ b/env.example
@@ -2,13 +2,37 @@
# REQUIRED ENVIRONMENT VARIABLES
# ================================
-# [REQUIRED]: PostgreSQL connection string for the PortalApp database used by the auth server.
+# [OPTIONAL]: Data source type - "postgres" or "postgrest"
+# - Default: "postgres" if not set
+# DATA_SOURCE_TYPE="postgres"
+
+# [REQUIRED when DATA_SOURCE_TYPE=postgres]: PostgreSQL connection string for the PortalApp database used by the auth server.
# - Example: "postgresql://username:password@localhost:5432/dbname"
POSTGRES_CONNECTION_STRING=
+# [REQUIRED when DATA_SOURCE_TYPE=postgrest]: PostgREST base URL
+# - Example: "https://db.rpc.com/api"
+# POSTGREST_BASE_URL="https://db.rpc.com/api"
+
+# [REQUIRED when DATA_SOURCE_TYPE=postgrest]: PostgREST JWT secret for authentication
+# - Example: "supersecretjwtsecretforlocaldevelopment123456789"
+# POSTGREST_JWT_SECRET="supersecretjwtsecretforlocaldevelopment123456789"
+
+# [REQUIRED when DATA_SOURCE_TYPE=postgrest]: PostgREST JWT role
+# POSTGREST_JWT_ROLE="admin"
+
+# [REQUIRED when DATA_SOURCE_TYPE=postgrest]: PostgREST JWT email for authentication
+# - Example: "service@rpc.com"
+# POSTGREST_JWT_EMAIL="service@rpc.com"
+
+# [OPTIONAL]: PostgREST request timeout
+# - Default: 30s if not set
+# - Examples: "30s", "1m", "2m30s"
+# POSTGREST_TIMEOUT="30s"
+
# [REQUIRED]: GCP project ID for the data warehouse used by the rate limit store.
# - Example: "your-project-id"
-GCP_PROJECT_ID=
+GCP_PROJECT_ID="your-project-id"
# ================================
# OPTIONAL ENVIRONMENT VARIABLES
diff --git a/env.go b/env.go
index 0cc0c60..47946c1 100644
--- a/env.go
+++ b/env.go
@@ -13,14 +13,41 @@ import (
)
const (
+ // [OPTIONAL]: Data source type - "postgres" or "postgrest"
+ // - Default: "postgres" if not set
+ dataSourceTypeEnv = "DATA_SOURCE_TYPE"
+ defaultDataSourceType = "postgres"
+
// [REQUIRED]: PostgreSQL connection string for the PortalApp database used by the auth server.
// - Example: "postgresql://username:password@localhost:5432/dbname"
postgresConnectionStringEnv = "POSTGRES_CONNECTION_STRING"
+ // [REQUIRED when DATA_SOURCE_TYPE=postgrest]: PostgREST base URL
+ // - Example: "https://db.rpc.com/api"
+ postgrestBaseURLEnv = "POSTGREST_BASE_URL"
+
+ // [REQUIRED when DATA_SOURCE_TYPE=postgrest]: JWT secret for PostgREST authentication
+ // - Example: "supersecretjwtsecretforlocaldevelopment123456789"
+ postgrestJWTSecretEnv = "POSTGREST_JWT_SECRET"
+
+ // [OPTIONAL]: JWT role for PostgREST authentication
+ // - Examples: "admin"
+ postgrestJWTRoleEnv = "POSTGREST_JWT_ROLE"
+
+ // [REQUIRED when DATA_SOURCE_TYPE=postgrest]: JWT email for PostgREST authentication
+ // - Example: "service@rpc.com"
+ postgrestJWTEmailEnv = "POSTGREST_JWT_EMAIL"
+
// [REQUIRED]: GCP project ID for the data warehouse used by the rate limit store.
// - Example: "your-project-id"
gcpProjectIDEnv = "GCP_PROJECT_ID"
+ // [OPTIONAL]: PostgREST request timeout
+ // - Default: 30s if not set
+ // - Examples: "30s", "1m", "2m30s"
+ postgrestTimeoutEnv = "POSTGREST_TIMEOUT"
+ defaultPostgrestTimeout = 30 * time.Second
+
// [OPTIONAL]: Port to run the external auth server on.
// - Default: 10001 if not set
portEnv = "PORT"
@@ -61,14 +88,31 @@ const (
var postgresConnectionStringRegex = regexp.MustCompile(`^postgres(?:ql)?:\/\/[^:]+:[^@]+@[^:]+:\d+\/[^?]+(?:\?.+)?$`)
+// DataSourceType represents the type of data source to use
+type DataSourceType string
+
+const (
+ DataSourceTypePostgres DataSourceType = "postgres"
+ DataSourceTypePostgREST DataSourceType = "postgrest"
+)
+
// envVars holds configuration values.
// - All fields are private.
// - Use gatherEnvVars to load, validate, and hydrate defaults from environment variables.
type envVars struct {
- // Database and external service configuration
+ // Database configuration
+ dataSourceType DataSourceType
+
postgresConnectionString string
- gcpProjectID string
+ postgrestBaseURL string
+ postgrestJWTSecret string
+ postgrestJWTRole string
+ postgrestJWTEmail string
+ postgrestTimeout time.Duration
+
+ // Data warehouse configuration
+ gcpProjectID string
// Server port configuration
port int
metricsPort int
@@ -87,10 +131,18 @@ type envVars struct {
// - Loads configuration from environment variables
// - Validates and hydrates defaults for missing/invalid values
func gatherEnvVars() (envVars, error) {
- // Initialize with Postgres connection string from environment
+ // Initialize with environment variables
e := envVars{
+ dataSourceType: DataSourceType(os.Getenv(dataSourceTypeEnv)),
+
postgresConnectionString: os.Getenv(postgresConnectionStringEnv),
- gcpProjectID: os.Getenv(gcpProjectIDEnv),
+
+ postgrestBaseURL: os.Getenv(postgrestBaseURLEnv),
+ postgrestJWTSecret: os.Getenv(postgrestJWTSecretEnv),
+ postgrestJWTRole: os.Getenv(postgrestJWTRoleEnv),
+ postgrestJWTEmail: os.Getenv(postgrestJWTEmailEnv),
+
+ gcpProjectID: os.Getenv(gcpProjectIDEnv),
}
// Parse port environment variable (if provided)
@@ -155,6 +207,16 @@ func gatherEnvVars() (envVars, error) {
e.rateLimitStoreRefreshInterval = duration
}
+ // Parse PostgREST timeout from environment (if provided)
+ postgrestTimeoutStr := os.Getenv(postgrestTimeoutEnv)
+ if postgrestTimeoutStr != "" {
+ duration, err := time.ParseDuration(postgrestTimeoutStr)
+ if err != nil {
+ return envVars{}, fmt.Errorf("invalid PostgREST timeout format: %v", err)
+ }
+ e.postgrestTimeout = duration
+ }
+
// Apply defaults for any unset configuration
e.hydrateDefaults()
@@ -167,23 +229,37 @@ func gatherEnvVars() (envVars, error) {
// validate checks that all required environment variables are set and valid
func (e *envVars) validate() error {
- // Postgres connection string must be set
- if e.postgresConnectionString == "" {
- return fmt.Errorf("%s is not set", postgresConnectionStringEnv)
- }
-
// GCP project ID must be set
if e.gcpProjectID == "" {
return fmt.Errorf("%s is not set", gcpProjectIDEnv)
}
- // Connection string must match expected format
- matched, err := regexp.MatchString(postgresConnectionStringRegex.String(), e.postgresConnectionString)
- if err != nil {
- return fmt.Errorf("failed to validate postgresConnectionString: %v", err)
- }
- if !matched {
- return fmt.Errorf("postgresConnectionString does not match the required pattern")
+ // Validate based on data source type
+ switch e.dataSourceType {
+ case DataSourceTypePostgres:
+ if e.postgresConnectionString == "" {
+ return fmt.Errorf("%s is required when DATA_SOURCE_TYPE=postgres", postgresConnectionStringEnv)
+ }
+ // Connection string must match expected format
+ matched, err := regexp.MatchString(postgresConnectionStringRegex.String(), e.postgresConnectionString)
+ if err != nil {
+ return fmt.Errorf("failed to validate postgresConnectionString: %v", err)
+ }
+ if !matched {
+ return fmt.Errorf("postgresConnectionString does not match the required pattern")
+ }
+ case DataSourceTypePostgREST:
+ if e.postgrestBaseURL == "" {
+ return fmt.Errorf("%s is required when DATA_SOURCE_TYPE=postgrest", postgrestBaseURLEnv)
+ }
+ if e.postgrestJWTSecret == "" {
+ return fmt.Errorf("%s is required when DATA_SOURCE_TYPE=postgrest", postgrestJWTSecretEnv)
+ }
+ if e.postgrestJWTEmail == "" {
+ return fmt.Errorf("%s is required when DATA_SOURCE_TYPE=postgrest", postgrestJWTEmailEnv)
+ }
+ default:
+ return fmt.Errorf("unsupported data source type: %s", e.dataSourceType)
}
return nil
@@ -191,6 +267,12 @@ func (e *envVars) validate() error {
// hydrateDefaults sets defaults for missing/invalid values
func (e *envVars) hydrateDefaults() {
+ if e.dataSourceType == "" {
+ e.dataSourceType = DataSourceType(defaultDataSourceType)
+ }
+ if e.postgrestTimeout == 0 {
+ e.postgrestTimeout = defaultPostgrestTimeout
+ }
if e.port == 0 {
e.port = defaultPort
}
diff --git a/go.mod b/go.mod
index d3bc7b5..633d3cf 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.24
require (
cloud.google.com/go/bigquery v1.69.0
+ github.com/buildwithgrove/path/portal-db/sdk/go v0.4.7
github.com/envoyproxy/go-control-plane/envoy v1.32.4
github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1
@@ -28,6 +29,7 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/apache/arrow/go/v15 v15.0.2 // indirect
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -40,8 +42,11 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.1.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -54,17 +59,24 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/term v0.5.0 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/oapi-codegen/runtime v1.1.1 // indirect
+ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
+ github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.2.3 // indirect
+ github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
@@ -74,6 +86,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rs/zerolog v1.32.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
diff --git a/go.sum b/go.sum
index f218334..05fa352 100644
--- a/go.sum
+++ b/go.sum
@@ -36,10 +36,16 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE=
github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/buildwithgrove/path/portal-db/sdk/go v0.4.7 h1:EdyA8eHC+eauAV54vkFFPIPzeBmJbbcTeT6AaOpWeG4=
+github.com/buildwithgrove/path/portal-db/sdk/go v0.4.7/go.mod h1:Mqzhv+MOs0YBm34p/Yf/eW+MEpMnyxB1hNtYZNC+fHc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -69,6 +75,8 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
+github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -76,8 +84,14 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w=
github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -113,6 +127,9 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -123,8 +140,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -137,8 +158,16 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
+github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -147,6 +176,8 @@ github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19o
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -175,6 +206,7 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@@ -182,6 +214,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
+github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
+github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
diff --git a/grafana/local/load_test.sh b/grafana/local/load_test.sh
index 6338d81..504909f 100755
--- a/grafana/local/load_test.sh
+++ b/grafana/local/load_test.sh
@@ -164,5 +164,5 @@ echo "✅ Successful: $successful_count"
echo "❌ Failed auth: $failed_count"
echo "⚠️ Errors: $error_count"
echo
-echo -e "${YELLOW}📊 Check your Grafana dashboard at http://localhost:3000${NC}"
+echo -e "${YELLOW}📊 Check your Grafana dashboard at https://db.rpc.com/api${NC}"
echo -e "${YELLOW}📈 Check metrics at http://localhost:9090/metrics${NC}"
diff --git a/main.go b/main.go
index d9e2609..e9b7aa2 100644
--- a/main.go
+++ b/main.go
@@ -15,6 +15,7 @@ import (
"github.com/buildwithgrove/path-external-auth-server/dwh"
"github.com/buildwithgrove/path-external-auth-server/metrics"
"github.com/buildwithgrove/path-external-auth-server/postgres/grove"
+ "github.com/buildwithgrove/path-external-auth-server/postgrest"
"github.com/buildwithgrove/path-external-auth-server/ratelimit"
"github.com/buildwithgrove/path-external-auth-server/store"
)
@@ -38,15 +39,15 @@ func main() {
// Create context for graceful shutdown
ctx := context.Background()
- // Create a new postgres data source
- postgresDataSource, err := grove.NewGrovePostgresDriver(
- logger, env.postgresConnectionString,
- )
+ // Create data source based on configuration
+ dataSource, err := createDataSource(env, logger)
if err != nil {
- panic(fmt.Sprintf("failed to connect to postgres: %v", err))
+ panic(fmt.Sprintf("failed to create data source: %v", err))
}
- defer postgresDataSource.Close()
- logger.Info().Msg("🐘 Successfully connected to postgres as a data source")
+ defer dataSource.Close()
+ logger.Info().
+ Str("data_source_type", string(env.dataSourceType)).
+ Msg("✅ Successfully connected to data source")
// Create a new data warehouse driver
dataWarehouseDriver, err := dwh.NewDriver(context.Background(), env.gcpProjectID)
@@ -59,7 +60,7 @@ func main() {
// Create a new portal app store
portalAppStore, err := store.NewPortalAppStore(
logger,
- postgresDataSource,
+ dataSource,
env.portalAppStoreRefreshInterval,
)
if err != nil {
@@ -135,3 +136,24 @@ func main() {
panic(err)
}
}
+
+// createDataSource creates the appropriate data source based on the configured type
+func createDataSource(env envVars, logger polylog.Logger) (store.DataSource, error) {
+ switch env.dataSourceType {
+ case DataSourceTypePostgres:
+ logger.Info().Msg("🐘 Creating Postgres data source")
+ return grove.NewGrovePostgresDriver(logger, env.postgresConnectionString)
+ case DataSourceTypePostgREST:
+ logger.Info().Msg("🌐 Creating PostgREST data source")
+ return postgrest.NewPostgRESTDriver(
+ logger,
+ env.postgrestBaseURL,
+ env.postgrestJWTSecret,
+ env.postgrestJWTRole,
+ env.postgrestJWTEmail,
+ env.postgrestTimeout,
+ )
+ default:
+ return nil, fmt.Errorf("unsupported data source type: %s", env.dataSourceType)
+ }
+}
diff --git a/postgrest/driver.go b/postgrest/driver.go
new file mode 100644
index 0000000..f7ad788
--- /dev/null
+++ b/postgrest/driver.go
@@ -0,0 +1,259 @@
+package postgrest
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/pokt-network/poktroll/pkg/polylog"
+
+ "github.com/buildwithgrove/path-external-auth-server/store"
+ portal_db_sdk "github.com/buildwithgrove/path/portal-db/sdk/go"
+)
+
+// PostgRESTDriver implements the store.DataSource interface
+// to provide data from PostgREST API for the portal app store.
+var _ store.DataSource = &PostgRESTDriver{}
+
+type PostgRESTDriver struct {
+ logger polylog.Logger
+
+ client *portal_db_sdk.ClientWithResponses
+
+ // JWT secret for generating authentication tokens
+ jwtSecret string
+ jwtRole string
+ jwtEmail string
+
+ timeout time.Duration
+}
+
+// NewPostgRESTDriver creates a new PostgREST driver that implements the store.DataSource interface.
+//
+// The driver connects to a PostgREST API and:
+// 1. Provides methods to fetch initial portal app data
+// 2. Uses JWT authentication for API requests
+// 3. Converts PostgREST responses to store.PortalApp format
+func NewPostgRESTDriver(
+ logger polylog.Logger,
+ baseURL string,
+ jwtSecret string,
+ jwtRole string,
+ jwtEmail string,
+ timeout time.Duration,
+) (*PostgRESTDriver, error) {
+ // Create PostgREST client with custom HTTP client
+ client, err := portal_db_sdk.NewClientWithResponses(baseURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create PostgREST client: %w", err)
+ }
+
+ driver := &PostgRESTDriver{
+ logger: logger,
+ client: client,
+ jwtSecret: jwtSecret,
+ jwtRole: jwtRole,
+ jwtEmail: jwtEmail,
+ timeout: timeout,
+ }
+
+ return driver, nil
+}
+
+// getRequestEditor generates a fresh JWT token and returns a request editor function
+func (d *PostgRESTDriver) getRequestEditor(token string) (portal_db_sdk.RequestEditorFn, error) {
+ // Return request editor with the generated token
+ return func(ctx context.Context, req *http.Request) error {
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ return nil
+ }, nil
+}
+
+// GetPortalApps loads the full set of PortalApps from the PostgREST API.
+// It uses a two-step approach:
+// 1. Fetch portal applications
+// 2. Fetch portal accounts to get plan types
+// 3. Merge the data and convert to store.PortalApp format
+func (d *PostgRESTDriver) GetPortalApps() (map[store.PortalAppID]*store.PortalApp, error) {
+ d.logger.Info().Msg("🌐 Executing PostgREST portal apps query...")
+
+ // Step 1: Get portal applications
+ applications, err := d.getPortalApplications()
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch portal applications: %w", err)
+ }
+
+ if len(applications) == 0 {
+ d.logger.Info().Msg("✅ No portal applications found")
+ return make(map[store.PortalAppID]*store.PortalApp), nil
+ }
+
+ // Step 2: Get all portal accounts (we'll match them in Go code)
+ accounts, err := d.getAllPortalAccounts()
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch portal accounts: %w", err)
+ }
+
+ d.logger.Info().
+ Int("num_applications", len(applications)).
+ Int("num_accounts", len(accounts)).
+ Msg("✅ Successfully fetched Portal Applications and Accounts from PostgREST")
+
+ // Step 3: Convert and merge data
+ return d.convertToPortalApps(applications, accounts), nil
+}
+
+// getPortalApplications fetches portal applications from PostgREST API
+func (d *PostgRESTDriver) getPortalApplications() ([]portal_db_sdk.PortalApplications, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), d.timeout)
+ defer cancel()
+
+ // Build select fields for readability
+ selectFields := strings.Join([]string{
+ "portal_application_id",
+ "portal_account_id",
+ "secret_key_hash",
+ "secret_key_required",
+ "portal_application_user_limit",
+ }, ",")
+
+ params := &portal_db_sdk.GetPortalApplicationsParams{
+ Select: stringPtr(selectFields),
+ DeletedAt: stringPtr("is.null"), // Only active applications
+ }
+
+ // Generate a fresh JWT token
+ token, err := generatePostgRESTJWT(d.jwtSecret, d.jwtRole, d.jwtEmail)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate JWT token: %w", err)
+ }
+
+ requestEditor, err := d.getRequestEditor(token)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request editor: %w", err)
+ }
+
+ resp, err := d.client.GetPortalApplicationsWithResponse(ctx, params, requestEditor)
+ if err != nil {
+ d.logger.Error().Err(err).Msg("failed to call PostgREST portal applications endpoint")
+ return nil, fmt.Errorf("failed to call PostgREST API: %w", err)
+ }
+
+ if resp.StatusCode() != 200 {
+ d.logger.Error().Int("status_code", resp.StatusCode()).Msg("unexpected status code from PostgREST")
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode())
+ }
+
+ // Parse JSON response directly
+ var applications []portal_db_sdk.PortalApplications
+ if err := json.Unmarshal(resp.Body, &applications); err != nil {
+ d.logger.Error().Err(err).Msg("failed to parse portal applications response")
+ return nil, fmt.Errorf("failed to parse JSON response: %w", err)
+ }
+
+ return applications, nil
+}
+
+// getAllPortalAccounts fetches all non-deleted portal accounts with plan types from PostgREST API
+func (d *PostgRESTDriver) getAllPortalAccounts() ([]portal_db_sdk.PortalAccounts, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), d.timeout)
+ defer cancel()
+
+ // Build select fields for minimal data transfer
+ selectFields := strings.Join([]string{
+ "portal_account_id",
+ "portal_plan_type",
+ "portal_account_user_limit",
+ }, ",")
+
+ params := &portal_db_sdk.GetPortalAccountsParams{
+ Select: stringPtr(selectFields),
+ DeletedAt: stringPtr("is.null"), // Only active accounts
+ }
+
+ // Generate a fresh JWT token
+ token, err := generatePostgRESTJWT(d.jwtSecret, d.jwtRole, d.jwtEmail)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate JWT token: %w", err)
+ }
+
+ requestEditor, err := d.getRequestEditor(token)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request editor: %w", err)
+ }
+
+ resp, err := d.client.GetPortalAccountsWithResponse(ctx, params, requestEditor)
+ if err != nil {
+ d.logger.Error().Err(err).Msg("failed to call PostgREST portal accounts endpoint")
+ return nil, fmt.Errorf("failed to call PostgREST API: %w", err)
+ }
+
+ if resp.StatusCode() != 200 {
+ d.logger.Error().Int("status_code", resp.StatusCode()).Msg("unexpected status code from PostgREST")
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode())
+ }
+
+ // Parse JSON response directly
+ var accounts []portal_db_sdk.PortalAccounts
+ if err := json.Unmarshal(resp.Body, &accounts); err != nil {
+ d.logger.Error().Err(err).Msg("failed to parse portal accounts response")
+ return nil, fmt.Errorf("failed to parse JSON response: %w", err)
+ }
+
+ return accounts, nil
+}
+
+// buildAccountPlanMap creates a map of account ID to plan type for efficient lookups
+func (d *PostgRESTDriver) buildAccountMap(accounts []portal_db_sdk.PortalAccounts) map[string]portal_db_sdk.PortalAccounts {
+ accountsMap := make(map[string]portal_db_sdk.PortalAccounts, len(accounts))
+ for _, account := range accounts {
+ accountsMap[account.PortalAccountId] = account
+ }
+ return accountsMap
+}
+
+// convertToPortalApps converts PostgREST responses to store.PortalApp format
+func (d *PostgRESTDriver) convertToPortalApps(
+ applications []portal_db_sdk.PortalApplications,
+ accounts []portal_db_sdk.PortalAccounts,
+) map[store.PortalAppID]*store.PortalApp {
+ // Build efficient lookup map for account plan types
+ accountsMap := d.buildAccountMap(accounts)
+
+ // Convert applications to PortalApps
+ portalApps := make(map[store.PortalAppID]*store.PortalApp, len(applications))
+
+ for _, app := range applications {
+ // Get plan type from account mapping
+ account, exists := accountsMap[app.PortalAccountId]
+ if !exists {
+ d.logger.Warn().
+ Str("portal_application_id", app.PortalApplicationId).
+ Str("portal_account_id", app.PortalAccountId).
+ Msg("⁉️ SHOULD NEVER HAPPEN - No plan type found for account, skipping application")
+ continue
+ }
+
+ // Convert SDK types directly to store.PortalApp
+ portalApp := convertSDKToPortalApp(app, account)
+
+ portalApps[store.PortalAppID(app.PortalApplicationId)] = portalApp
+ }
+
+ return portalApps
+}
+
+// Close is a no-op for the PostgREST driver.
+// TODO_TECHDEBT(@commoddity): Remove this method when direct Postgres data source is removed.
+func (d *PostgRESTDriver) Close() {
+ // Do nothing; only here to satisfy the DataSource interface
+}
+
+// stringPtr is a helper function to create string pointers
+func stringPtr(s string) *string {
+ return &s
+}
diff --git a/postgrest/jwt.go b/postgrest/jwt.go
new file mode 100644
index 0000000..57c8a33
--- /dev/null
+++ b/postgrest/jwt.go
@@ -0,0 +1,52 @@
+package postgrest
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+// Use const header since it never changes for PostgREST
+const (
+ header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" // {"alg":"HS256","typ":"JWT"}
+ expiration = 1 * time.Hour
+)
+
+// generatePostgRESTJWT creates a JWT token for PostgREST authentication using Go best practices.
+// Uses stdlib encoding/base64.RawURLEncoding which handles URL-safe base64 without padding.
+func generatePostgRESTJWT(secret, role, email string) (string, error) {
+ now := time.Now()
+
+ // Create payload with minimal required claims for PostgREST
+ payload := map[string]interface{}{
+ "role": role,
+ "email": email,
+ "exp": now.Add(expiration).Unix(),
+ }
+
+ // Marshal payload to JSON
+ payloadJSON, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JWT payload: %w", err)
+ }
+
+ // Encode payload using stdlib RawURLEncoding (no padding, URL-safe)
+ encodedPayload := base64.RawURLEncoding.EncodeToString(payloadJSON)
+
+ // Create unsigned token
+ unsignedToken := header + "." + encodedPayload
+
+ // Sign with HMAC-SHA256
+ h := hmac.New(sha256.New, []byte(secret))
+ h.Write([]byte(unsignedToken))
+ signature := h.Sum(nil)
+
+ // Encode signature using stdlib RawURLEncoding
+ encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
+
+ // Return complete JWT
+ return unsignedToken + "." + encodedSignature, nil
+}
diff --git a/postgrest/portal_app.go b/postgrest/portal_app.go
new file mode 100644
index 0000000..69de957
--- /dev/null
+++ b/postgrest/portal_app.go
@@ -0,0 +1,63 @@
+package postgrest
+
+import (
+ portal_db_sdk "github.com/buildwithgrove/path/portal-db/sdk/go"
+
+ "github.com/buildwithgrove/path-external-auth-server/store"
+)
+
+const (
+ // Plan types from the database - matching Grove's implementation
+ PlanFree_DatabaseType store.PlanType = "PLAN_FREE"
+ PlanUnlimited_DatabaseType store.PlanType = "PLAN_UNLIMITED"
+)
+
+// convertSDKToPortalApp converts SDK types directly to store.PortalApp
+// combining portal_db_sdk.PortalApplications and portal_db_sdk.PortalAccounts data.
+func convertSDKToPortalApp(
+ app portal_db_sdk.PortalApplications,
+ account portal_db_sdk.PortalAccounts,
+) *store.PortalApp {
+ planType := store.PlanType(account.PortalPlanType)
+ return &store.PortalApp{
+ ID: store.PortalAppID(app.PortalApplicationId),
+ AccountID: store.AccountID(app.PortalAccountId),
+ PlanType: planType,
+ Auth: getAuthDetailsFromSDK(app),
+ RateLimit: getRateLimitDetailsFromSDK(account),
+ }
+}
+
+// getAuthDetailsFromSDK determines the authentication configuration from SDK data
+func getAuthDetailsFromSDK(app portal_db_sdk.PortalApplications) *store.Auth {
+ if app.SecretKeyRequired != nil && *app.SecretKeyRequired && app.SecretKeyHash != nil {
+ return &store.Auth{
+ APIKey: *app.SecretKeyHash,
+ }
+ }
+
+ return nil
+}
+
+// getRateLimitDetailsFromSDK determines the rate limiting configuration from SDK data
+func getRateLimitDetailsFromSDK(account portal_db_sdk.PortalAccounts) *store.RateLimit {
+ // The following scenarios are rate limited:
+ // - PLAN_FREE
+ // - PLAN_UNLIMITED with a user-specified monthly user limits
+ planType := store.PlanType(account.PortalPlanType)
+
+ // Free plans are always rate limited so we set the limit a non-nil RateLimit field.
+ if planType == PlanFree_DatabaseType {
+ return &store.RateLimit{}
+ }
+
+ // Unlimited plans with optional user-specified limits are rate limited.
+ if planType == PlanUnlimited_DatabaseType && account.PortalAccountUserLimit != nil && *account.PortalAccountUserLimit > 0 {
+ return &store.RateLimit{
+ MonthlyUserLimit: int32(*account.PortalAccountUserLimit),
+ }
+ }
+
+ // All other scenarios are not rate limited
+ return nil
+}
diff --git a/store/data_source.go b/store/data_source.go
index 4f3caec..feb6475 100644
--- a/store/data_source.go
+++ b/store/data_source.go
@@ -10,6 +10,7 @@ type DataSource interface {
// FetchInitialData loads the initial set of portal apps.
GetPortalApps() (map[PortalAppID]*PortalApp, error)
+ // TODO_TECHDEBT(@commoddity): Remove this method when direct Postgres data source is removed.
// Close closes the data source and cleans up any resources.
Close()
}