This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch auth
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 9f397b914ca7e0da8e03b69617ca3dfe5853bf83
Author: lahiruj <[email protected]>
AuthorDate: Tue Jun 23 04:03:01 2026 -0400

    Add identity resolver and core.auth configuration
---
 config/custos.yaml               |   8 +++
 docs/CONFIG.md                   |  47 ++++++++++++++++
 internal/config/config.go        |  47 +++++++++++++++-
 internal/config/config_test.go   |  96 +++++++++++++++++++++++++++++++
 internal/store/store.go          |   3 +
 internal/store/user_store.go     |  33 +++++++++++
 pkg/identity/identity.go         |   4 +-
 pkg/identity/verifier.go         |   3 +-
 pkg/service/identity_resolver.go | 119 +++++++++++++++++++++++++++++++++++++++
 pkg/service/service.go           |   3 +
 10 files changed, 357 insertions(+), 6 deletions(-)

diff --git a/config/custos.yaml b/config/custos.yaml
index 6296d21a7..7568b510e 100644
--- a/config/custos.yaml
+++ b/config/custos.yaml
@@ -4,6 +4,14 @@ core:
   api:
     port: 8080
   log_level: "info"
+  auth:
+    oidc:
+      issuer:   "${OIDC_ISSUER_URL}"
+      audience: "${OIDC_AUDIENCE}"
+    cache_ttl: "30s"
+    public_paths:
+      - "/healthz"
+      - "/ready"
 
 connectors:
   slurm-mapper:
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
new file mode 100644
index 000000000..1667acb59
--- /dev/null
+++ b/docs/CONFIG.md
@@ -0,0 +1,47 @@
+# Custos configuration
+
+The server reads `config/custos.yaml` at boot. `${VAR}` substitution keeps
+secrets out of the file; an unset var stays as the literal `${VAR}` so
+missing-secret failures surface at the consumer, not at load time.
+
+## Core
+
+| Key | Default | Notes |
+|-----|---------|-------|
+| `core.database.url` | *(required)* | MariaDB / MySQL DSN. `parseTime=true` 
is mandatory. |
+| `core.api.port` | `8080` | HTTP API port. |
+| `core.log_level` | `info` | One of `debug`, `info`, `warn`, `error`. |
+
+## Auth
+
+The verifier + caller resolver read this block. The loader refuses to boot
+when either OIDC field is empty; the cache TTL is capped at 60s.
+
+| Key | Default | Notes |
+|-----|---------|-------|
+| `core.auth.oidc.issuer` | *(required)* | OIDC issuer URL. The verifier 
discovers JWKS from `<issuer>/.well-known/openid-configuration`. Empty → boot 
fails with `core.auth.oidc.issuer is required`. |
+| `core.auth.oidc.audience` | *(required)* | Expected `aud` claim. Usually the 
client_id registered with the IdP. Empty → boot fails with 
`core.auth.oidc.audience is required`. |
+| `core.auth.cache_ttl` | `30s` | TTL of the in-process caller + privilege 
cache, keyed by OIDC sub. Units: any `time.Duration` string (`s`, `m`). Zero or 
negative → `30s`. Values above `60s` are capped at `60s` at boot and a WARN is 
logged. |
+| `core.auth.public_paths` | `[]` | List of path components that bypass JWT 
verification entirely. Typically `/healthz` and `/ready`. Routes registered via 
`router.Public(...)` are merged with this list at server bootstrap. |
+
+### Example
+
+```yaml
+core:
+  auth:
+    oidc:
+      issuer:   "${OIDC_ISSUER_URL}"
+      audience: "${OIDC_AUDIENCE}"
+    cache_ttl: "30s"
+    public_paths:
+      - "/healthz"
+      - "/ready"
+```
+
+## Environment overrides
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `DB_MAX_OPEN_CONNS` | `25` | Maximum open database connections. |
+| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle database connections. |
+| `CONFIG_PATH` | `config/custos.yaml` | Override the config file location. |
diff --git a/internal/config/config.go b/internal/config/config.go
index bbffcfb92..ecceb6a5e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,6 +19,7 @@ package config
 
 import (
        "fmt"
+       "log/slog"
        "os"
        "regexp"
        "time"
@@ -26,8 +27,16 @@ import (
        "gopkg.in/yaml.v3"
 )
 
+// IdentityCacheMaxTTL caps how long the caller resolver may hold a verified
+// identity in process memory.
+const IdentityCacheMaxTTL = 60 * time.Second
+
+// IdentityCacheDefaultTTL is the TTL applied when core.auth.cache_ttl is unset
+// or non-positive.
+const IdentityCacheDefaultTTL = 30 * time.Second
+
 type Config struct {
-       Core       CoreConfig `yaml:"core"`
+       Core       CoreConfig                  `yaml:"core"`
        Connectors map[string]*ConnectorConfig `yaml:"connectors"`
 }
 
@@ -35,6 +44,7 @@ type CoreConfig struct {
        Database DatabaseConfig `yaml:"database"`
        API      APIConfig      `yaml:"api"`
        LogLevel string         `yaml:"log_level"`
+       Auth     AuthConfig     `yaml:"auth"`
 }
 
 type DatabaseConfig struct {
@@ -45,6 +55,17 @@ type APIConfig struct {
        Port int `yaml:"port"`
 }
 
+type AuthConfig struct {
+       OIDC        OIDCConfig    `yaml:"oidc"`
+       CacheTTL    time.Duration `yaml:"cache_ttl"`
+       PublicPaths []string      `yaml:"public_paths"`
+}
+
+type OIDCConfig struct {
+       Issuer   string `yaml:"issuer"`
+       Audience string `yaml:"audience"`
+}
+
 type ConnectorConfig struct {
        Type    string                 `yaml:"type"`
        Enabled bool                   `yaml:"enabled"`
@@ -64,9 +85,33 @@ func LoadConfig(path string) (*Config, error) {
                return nil, fmt.Errorf("failed to parse config file: %w", err)
        }
 
+       if err := applyAuthDefaults(&cfg.Core.Auth); err != nil {
+               return nil, err
+       }
+
        return &cfg, nil
 }
 
+// applyAuthDefaults enforces the auth invariants:
+//   - Issuer and Audience are required.
+//   - CacheTTL <= 0 collapses to IdentityCacheDefaultTTL.
+//   - CacheTTL > IdentityCacheMaxTTL caps at the ceiling and logs a warning.
+func applyAuthDefaults(a *AuthConfig) error {
+       if a.OIDC.Issuer == "" {
+               return fmt.Errorf("core.auth.oidc.issuer is required")
+       }
+       if a.OIDC.Audience == "" {
+               return fmt.Errorf("core.auth.oidc.audience is required")
+       }
+       if a.CacheTTL <= 0 {
+               a.CacheTTL = IdentityCacheDefaultTTL
+       } else if a.CacheTTL > IdentityCacheMaxTTL {
+               slog.Warn("core.auth.cache_ttl above cap; capping", 
"configured", a.CacheTTL, "cap", IdentityCacheMaxTTL)
+               a.CacheTTL = IdentityCacheMaxTTL
+       }
+       return nil
+}
+
 func expandEnvVars(input string) string {
        re := regexp.MustCompile(`\$\{([^}]+)\}`)
        return re.ReplaceAllStringFunc(input, func(match string) string {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 787fbe078..3ab363569 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -19,12 +19,25 @@ package config
 
 import (
        "os"
+       "path/filepath"
        "testing"
+       "time"
 
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
 )
 
+// writeTempYAML writes contents to a temp file and returns its path.
+func writeTempYAML(t *testing.T, contents string) string {
+       t.Helper()
+       dir := t.TempDir()
+       p := filepath.Join(dir, "custos.yaml")
+       if err := os.WriteFile(p, []byte(contents), 0o600); err != nil {
+               t.Fatalf("write temp yaml: %v", err)
+       }
+       return p
+}
+
 func TestLoadConfig(t *testing.T) {
        cfg, err := LoadConfig("../../config/custos.yaml")
        require.NoError(t, err)
@@ -100,3 +113,86 @@ func TestConnectorConfigGetters(t *testing.T) {
                }
        })
 }
+
+func TestLoadConfig_AuthDefaults(t *testing.T) {
+       cfg, err := LoadConfig("../../config/custos.yaml")
+       require.NoError(t, err)
+
+       assert.Equal(t, 30*time.Second, cfg.Core.Auth.CacheTTL)
+       assert.Equal(t, []string{"/healthz", "/ready"}, 
cfg.Core.Auth.PublicPaths)
+       assert.NotEmpty(t, cfg.Core.Auth.OIDC.Issuer, "issuer should not be 
empty (env-unset stays as ${...} literal)")
+       assert.NotEmpty(t, cfg.Core.Auth.OIDC.Audience)
+}
+
+func TestLoadConfig_AuthRejectsEmptyIssuer(t *testing.T) {
+       yaml := `core:
+  database:
+    url: "dsn"
+  api:
+    port: 8080
+  log_level: "info"
+  auth:
+    oidc:
+      issuer: ""
+      audience: "aud"
+`
+       _, err := LoadConfig(writeTempYAML(t, yaml))
+       if err == nil {
+               t.Fatal("expected error for empty issuer, got nil")
+       }
+       assert.Contains(t, err.Error(), "core.auth.oidc.issuer")
+}
+
+func TestLoadConfig_AuthRejectsEmptyAudience(t *testing.T) {
+       yaml := `core:
+  database:
+    url: "dsn"
+  api:
+    port: 8080
+  log_level: "info"
+  auth:
+    oidc:
+      issuer: "https://idp.example";
+      audience: ""
+`
+       _, err := LoadConfig(writeTempYAML(t, yaml))
+       if err == nil {
+               t.Fatal("expected error for empty audience, got nil")
+       }
+       assert.Contains(t, err.Error(), "core.auth.oidc.audience")
+}
+
+func TestLoadConfig_AuthClampsTTLAboveMax(t *testing.T) {
+       yaml := `core:
+  database:
+    url: "dsn"
+  api:
+    port: 8080
+  log_level: "info"
+  auth:
+    oidc:
+      issuer: "https://idp.example";
+      audience: "aud"
+    cache_ttl: "5m"
+`
+       cfg, err := LoadConfig(writeTempYAML(t, yaml))
+       require.NoError(t, err)
+       assert.Equal(t, IdentityCacheMaxTTL, cfg.Core.Auth.CacheTTL)
+}
+
+func TestLoadConfig_AuthDefaultsTTLWhenZero(t *testing.T) {
+       yaml := `core:
+  database:
+    url: "dsn"
+  api:
+    port: 8080
+  log_level: "info"
+  auth:
+    oidc:
+      issuer: "https://idp.example";
+      audience: "aud"
+`
+       cfg, err := LoadConfig(writeTempYAML(t, yaml))
+       require.NoError(t, err)
+       assert.Equal(t, IdentityCacheDefaultTTL, cfg.Core.Auth.CacheTTL)
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index b473c7bc8..604e1d58e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -31,6 +31,9 @@ type UserStore interface {
        FindByID(ctx context.Context, id string) (*models.User, error)
        // FindByEmail returns the user with the given email, or nil if not 
found.
        FindByEmail(ctx context.Context, email string) (*models.User, error)
+       // GetUserByOIDCSub returns the user owning the user_identities row 
whose
+       // oidc_sub matches or nil if no row links the given subject.
+       GetUserByOIDCSub(ctx context.Context, oidcSub string) (*models.User, 
error)
        // FindByOrganization returns all users belonging to the given 
organization.
        FindByOrganization(ctx context.Context, organizationID string) 
([]models.User, error)
        // Create inserts a new user within the provided transaction.
diff --git a/internal/store/user_store.go b/internal/store/user_store.go
index 32d89da1f..9b2c065f3 100644
--- a/internal/store/user_store.go
+++ b/internal/store/user_store.go
@@ -21,6 +21,7 @@ import (
        "context"
        "database/sql"
        "errors"
+       "strings"
 
        "github.com/jmoiron/sqlx"
 
@@ -64,6 +65,38 @@ func (s *mysqlUserStore) FindByEmail(ctx context.Context, 
email string) (*models
        return &u, nil
 }
 
+// GetUserByOIDCSub returns the user owning the user_identities row whose
+// oidc_sub matches. Returns nil when the OIDC subject is empty or no row
+// links it to a Custos user.
+func (s *mysqlUserStore) GetUserByOIDCSub(ctx context.Context, oidcSub string) 
(*models.User, error) {
+       if oidcSub == "" {
+               return nil, nil
+       }
+       var u models.User
+       err := s.db.GetContext(ctx, &u,
+               `SELECT `+prefixed("u", userColumns)+`
+                FROM users u
+                JOIN user_identities ui ON ui.user_id = u.id
+                WHERE ui.oidc_sub = ?`, oidcSub)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &u, nil
+}
+
+// prefixed returns the comma-separated column list with each bare column
+// prefixed by alias. Used to disambiguate joined queries.
+func prefixed(alias, columns string) string {
+       parts := strings.Split(columns, ", ")
+       for i, p := range parts {
+               parts[i] = alias + "." + p
+       }
+       return strings.Join(parts, ", ")
+}
+
 func (s *mysqlUserStore) FindByOrganization(ctx context.Context, 
organizationID string) ([]models.User, error) {
        var users []models.User
        err := s.db.SelectContext(ctx, &users,
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
index 2564392d7..d50bfbef3 100644
--- a/pkg/identity/identity.go
+++ b/pkg/identity/identity.go
@@ -29,9 +29,7 @@ import (
 
 // Caller is the verified identity behind an HTTP request.
 type Caller struct {
-       UserID  string
-       OIDCSub string
-       Email   string
+       UserID string
 }
 
 // ErrNotLinked is returned by a UserResolver when the OIDC sub has no row in
diff --git a/pkg/identity/verifier.go b/pkg/identity/verifier.go
index 8044730ad..24314d063 100644
--- a/pkg/identity/verifier.go
+++ b/pkg/identity/verifier.go
@@ -29,8 +29,7 @@ type JWTVerifier struct{ inner *oidc.IDTokenVerifier }
 
 // Claims are the minimal subset the middleware needs after verification.
 type Claims struct {
-       Sub   string `json:"sub"`
-       Email string `json:"email"`
+       Sub string `json:"sub"`
 }
 
 // NewJWTVerifier resolves the provider's discovery doc and builds a verifier
diff --git a/pkg/service/identity_resolver.go b/pkg/service/identity_resolver.go
new file mode 100644
index 000000000..84512e6ff
--- /dev/null
+++ b/pkg/service/identity_resolver.go
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "sync"
+       "time"
+
+       "github.com/apache/airavata-custos/pkg/identity"
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// identityCacheDefaultTTL is the fallback TTL when SetIdentityCacheTTL is 
never
+// invoked or receives a non-positive value. The config loader applies the
+// same default and enforces a 60s ceiling before the value reaches the cache.
+const identityCacheDefaultTTL = 30 * time.Second
+
+type identityCacheEntry struct {
+       caller  *identity.Caller
+       privs   []models.PrivilegeKey
+       expires time.Time
+}
+
+// identityCache is the sub-keyed in-process cache the resolver consults before
+// hitting the database. Entries expire on read after TTL.
+type identityCache struct {
+       mu      sync.Mutex
+       entries map[string]identityCacheEntry
+       ttl     time.Duration
+}
+
+func newIdentityCache() *identityCache {
+       return &identityCache{entries: map[string]identityCacheEntry{}, ttl: 
identityCacheDefaultTTL}
+}
+
+func (c *identityCache) get(sub string, now time.Time) (identityCacheEntry, 
bool) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+       e, ok := c.entries[sub]
+       if !ok {
+               return identityCacheEntry{}, false
+       }
+       if !now.Before(e.expires) {
+               delete(c.entries, sub)
+               return identityCacheEntry{}, false
+       }
+       return e, true
+}
+
+func (c *identityCache) set(sub string, caller *identity.Caller, privs 
[]models.PrivilegeKey, now time.Time) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+       c.entries[sub] = identityCacheEntry{caller: caller, privs: privs, 
expires: now.Add(c.ttl)}
+}
+
+func (c *identityCache) invalidateAll() {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+       c.entries = map[string]identityCacheEntry{}
+}
+
+func (c *identityCache) setTTL(d time.Duration) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+       if d <= 0 {
+               d = identityCacheDefaultTTL
+       }
+       c.ttl = d
+}
+
+// SetIdentityCacheTTL configures the resolver cache's TTL. The config loader
+// caps the value at the 60s ceiling before it reaches the cache.
+func (s *Service) SetIdentityCacheTTL(d time.Duration) {
+       s.identityCache.setTTL(d)
+}
+
+// InvalidateAllIdentities empties the cache.
+func (s *Service) InvalidateAllIdentities() {
+       s.identityCache.invalidateAll()
+}
+
+// ResolveCaller maps a verified OIDC sub to a Caller + effective privilege 
set.
+// Returns ErrNotLinked when no user_identities row references sub.
+func (s *Service) ResolveCaller(ctx context.Context, oidcSub string) 
(*identity.Caller, []models.PrivilegeKey, error) {
+       now := time.Now()
+       if hit, ok := s.identityCache.get(oidcSub, now); ok {
+               return hit.caller, hit.privs, nil
+       }
+       user, err := s.users.GetUserByOIDCSub(ctx, oidcSub)
+       if err != nil {
+               return nil, nil, err
+       }
+       if user == nil {
+               return nil, nil, identity.ErrNotLinked
+       }
+       privs, err := s.EffectivePrivileges(ctx, user.ID)
+       if err != nil {
+               return nil, nil, err
+       }
+       caller := &identity.Caller{UserID: user.ID}
+       s.identityCache.set(oidcSub, caller, privs, now)
+       return caller, privs, nil
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index af2635aab..c1737d7ab 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -57,6 +57,7 @@ type Service struct {
        privileges          store.UserPrivilegeStore
        roles               store.RoleStore
        userRoles           store.UserRoleStore
+       identityCache       *identityCache
 }
 
 // New constructs a Service backed by the supplied database handle.
@@ -86,6 +87,7 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
                privileges:          store.NewUserPrivilegeStore(database),
                roles:               store.NewRoleStore(database),
                userRoles:           store.NewUserRoleStore(database),
+               identityCache:       newIdentityCache(),
        }
 }
 
@@ -141,6 +143,7 @@ func NewWithStores(
                privileges:          privileges,
                roles:               roles,
                userRoles:           userRoles,
+               identityCache:       newIdentityCache(),
        }
 }
 

Reply via email to