mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
)
|
||||
|
||||
// HeaderAuthenticator provides simple header-based authentication
|
||||
// Expects: X-User-ID, X-User-Name, X-User-Level, X-Session-ID, X-Remote-ID, X-User-Roles, X-User-Email
|
||||
type HeaderAuthenticator struct{}
|
||||
|
||||
func NewHeaderAuthenticator() *HeaderAuthenticator {
|
||||
return &HeaderAuthenticator{}
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
||||
return nil, fmt.Errorf("header authentication does not support login")
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) LoginWithCookie(ctx context.Context, req sectypes.LoginRequest, w http.ResponseWriter) (*sectypes.LoginResponse, error) {
|
||||
return a.Login(ctx, req)
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Logout(ctx context.Context, req sectypes.LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) LogoutWithCookie(ctx context.Context, req sectypes.LogoutRequest, w http.ResponseWriter) error {
|
||||
return a.Logout(ctx, req)
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*sectypes.UserContext, error) {
|
||||
userIDStr := r.Header.Get("X-User-ID")
|
||||
if userIDStr == "" {
|
||||
return nil, fmt.Errorf("X-User-ID header required")
|
||||
}
|
||||
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %w", err)
|
||||
}
|
||||
|
||||
return §ypes.UserContext{
|
||||
UserID: userID,
|
||||
UserName: r.Header.Get("X-User-Name"),
|
||||
UserLevel: parseIntHeader(r, "X-User-Level", 0),
|
||||
SessionID: r.Header.Get("X-Session-ID"),
|
||||
RemoteID: r.Header.Get("X-Remote-ID"),
|
||||
Email: r.Header.Get("X-User-Email"),
|
||||
Roles: parseRoles(r.Header.Get("X-User-Roles")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseRoles(rolesStr string) []string {
|
||||
if rolesStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(rolesStr, ",")
|
||||
}
|
||||
|
||||
func parseIntHeader(r *http.Request, key string, defaultVal int) int {
|
||||
val := r.Header.Get(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
intVal, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return intVal
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyStoreAuthenticator implements the Authenticator interface using a KeyStore.
|
||||
// It is suitable for long-lived application credentials (API keys, JWT secrets, etc.)
|
||||
// rather than interactive sessions. Login and Logout are not supported — key lifecycle
|
||||
// is managed directly through the KeyStore.
|
||||
//
|
||||
// Key extraction order:
|
||||
// 1. Authorization: Bearer <key>
|
||||
// 2. Authorization: ApiKey <key>
|
||||
// 3. X-API-Key header
|
||||
type KeyStoreAuthenticator struct {
|
||||
keyStore security.KeyStore
|
||||
keyType sectypes.KeyType // empty = accept any type
|
||||
authenticateCallback func(r *http.Request) (*sectypes.UserContext, error)
|
||||
}
|
||||
|
||||
// NewKeyStoreAuthenticator creates a KeyStoreAuthenticator.
|
||||
// Pass an empty keyType to accept keys of any type.
|
||||
func NewKeyStoreAuthenticator(ks security.KeyStore, keyType sectypes.KeyType) *KeyStoreAuthenticator {
|
||||
return &KeyStoreAuthenticator{keyStore: ks, keyType: keyType}
|
||||
}
|
||||
|
||||
// Login is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) Login(_ context.Context, _ sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
||||
return nil, fmt.Errorf("keystore authenticator does not support login")
|
||||
}
|
||||
|
||||
// LoginWithCookie is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) LoginWithCookie(_ context.Context, _ sectypes.LoginRequest, _ http.ResponseWriter) (*sectypes.LoginResponse, error) {
|
||||
return nil, fmt.Errorf("keystore authenticator does not support login")
|
||||
}
|
||||
|
||||
// Logout is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) Logout(_ context.Context, _ sectypes.LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutWithCookie is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) LogoutWithCookie(_ context.Context, _ sectypes.LogoutRequest, _ http.ResponseWriter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAuthenticateCallback registers a fallback called when key authentication fails.
|
||||
func (a *KeyStoreAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*sectypes.UserContext, error)) {
|
||||
a.authenticateCallback = fn
|
||||
}
|
||||
|
||||
// Authenticate extracts an API key from the request and validates it against the KeyStore.
|
||||
// Returns a UserContext built from the matching UserKey on success.
|
||||
func (a *KeyStoreAuthenticator) Authenticate(r *http.Request) (*sectypes.UserContext, error) {
|
||||
rawKey := extractAPIKey(r)
|
||||
if rawKey == "" {
|
||||
if a.authenticateCallback != nil {
|
||||
return a.authenticateCallback(r)
|
||||
}
|
||||
return nil, fmt.Errorf("API key required (Authorization: Bearer/ApiKey <key> or X-API-Key header)")
|
||||
}
|
||||
|
||||
userKey, err := a.keyStore.ValidateKey(r.Context(), rawKey, a.keyType)
|
||||
if err != nil {
|
||||
if a.authenticateCallback != nil {
|
||||
return a.authenticateCallback(r)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid API key: %w", err)
|
||||
}
|
||||
|
||||
return userKeyToUserContext(userKey), nil
|
||||
}
|
||||
|
||||
// extractAPIKey extracts a raw key from the request using the following precedence:
|
||||
// 1. Authorization: Bearer <key>
|
||||
// 2. Authorization: ApiKey <key>
|
||||
// 3. X-API-Key header
|
||||
func extractAPIKey(r *http.Request) string {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(auth, "ApiKey "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||||
}
|
||||
|
||||
// userKeyToUserContext converts a UserKey into a UserContext.
|
||||
// Scopes are mapped to Roles. Key type and name are stored in Claims.
|
||||
func userKeyToUserContext(k *sectypes.UserKey) *sectypes.UserContext {
|
||||
claims := map[string]any{
|
||||
"key_type": string(k.KeyType),
|
||||
"key_name": k.Name,
|
||||
}
|
||||
|
||||
meta := k.Meta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
roles := k.Scopes
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
|
||||
return §ypes.UserContext{
|
||||
UserID: k.UserID,
|
||||
SessionID: fmt.Sprintf("key:%d", k.ID),
|
||||
Roles: roles,
|
||||
Claims: claims,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigKeyStore is an in-memory keystore backed by a static slice of UserKey values.
|
||||
// It is designed for config-file driven setups (e.g. service accounts defined in YAML)
|
||||
// with a small, bounded number of keys. For large or dynamic key sets use DatabaseKeyStore.
|
||||
//
|
||||
// Pre-existing entries must have KeyHash set to the SHA-256 hex of the intended raw key.
|
||||
// Keys created at runtime via CreateKey are held in memory only and lost on restart.
|
||||
type ConfigKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys []sectypes.UserKey
|
||||
next int64 // monotonic ID counter for runtime-created keys (atomic)
|
||||
}
|
||||
|
||||
// NewConfigKeyStore creates a ConfigKeyStore seeded with the provided keys.
|
||||
// Pass nil or an empty slice to start with no pre-loaded keys.
|
||||
// Zero-value entries (CreatedAt is zero) are treated as active and assigned the current time.
|
||||
func NewConfigKeyStore(keys []sectypes.UserKey) *ConfigKeyStore {
|
||||
var maxID int64
|
||||
copied := make([]sectypes.UserKey, len(keys))
|
||||
copy(copied, keys)
|
||||
for i := range copied {
|
||||
if copied[i].CreatedAt.IsZero() {
|
||||
copied[i].IsActive = true
|
||||
copied[i].CreatedAt = time.Now()
|
||||
}
|
||||
if copied[i].ID > maxID {
|
||||
maxID = copied[i].ID
|
||||
}
|
||||
}
|
||||
return &ConfigKeyStore{keys: copied, next: maxID}
|
||||
}
|
||||
|
||||
// CreateKey generates a new raw key, stores its SHA-256 hash, and returns the raw key once.
|
||||
func (s *ConfigKeyStore) CreateKey(_ context.Context, req sectypes.CreateKeyRequest) (*sectypes.CreateKeyResponse, error) {
|
||||
rawBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key material: %w", err)
|
||||
}
|
||||
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||
hash := sectypes.HashKey(rawKey)
|
||||
|
||||
id := atomic.AddInt64(&s.next, 1)
|
||||
key := sectypes.UserKey{
|
||||
ID: id,
|
||||
UserID: req.UserID,
|
||||
KeyType: req.KeyType,
|
||||
KeyHash: hash,
|
||||
Name: req.Name,
|
||||
Scopes: req.Scopes,
|
||||
Meta: req.Meta,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.keys = append(s.keys, key)
|
||||
s.mu.Unlock()
|
||||
|
||||
return §ypes.CreateKeyResponse{Key: key, RawKey: rawKey}, nil
|
||||
}
|
||||
|
||||
// GetUserKeys returns all active, non-expired keys for the given user.
|
||||
// Pass an empty KeyType to return all types.
|
||||
func (s *ConfigKeyStore) GetUserKeys(_ context.Context, userID int, keyType sectypes.KeyType) ([]sectypes.UserKey, error) {
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []sectypes.UserKey
|
||||
for i := range s.keys {
|
||||
k := &s.keys[i]
|
||||
if k.UserID != userID || !k.IsActive {
|
||||
continue
|
||||
}
|
||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
if keyType != "" && k.KeyType != keyType {
|
||||
continue
|
||||
}
|
||||
result = append(result, *k)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteKey soft-deletes a key by setting IsActive to false after ownership verification.
|
||||
func (s *ConfigKeyStore) DeleteKey(_ context.Context, userID int, keyID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.keys {
|
||||
if s.keys[i].ID == keyID {
|
||||
if s.keys[i].UserID != userID {
|
||||
return fmt.Errorf("key not found or permission denied")
|
||||
}
|
||||
s.keys[i].IsActive = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
// ValidateKey hashes the raw key and finds a matching, active, non-expired entry.
|
||||
// Uses constant-time comparison to prevent timing side-channels.
|
||||
// Pass an empty KeyType to accept any type.
|
||||
func (s *ConfigKeyStore) ValidateKey(_ context.Context, rawKey string, keyType sectypes.KeyType) (*sectypes.UserKey, error) {
|
||||
hash := sectypes.HashKey(rawKey)
|
||||
hashBytes, _ := hex.DecodeString(hash)
|
||||
now := time.Now()
|
||||
|
||||
// Write lock: ValidateKey updates LastUsedAt on the matched entry.
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.keys {
|
||||
k := &s.keys[i]
|
||||
if !k.IsActive {
|
||||
continue
|
||||
}
|
||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
if keyType != "" && k.KeyType != keyType {
|
||||
continue
|
||||
}
|
||||
stored, _ := hex.DecodeString(k.KeyHash)
|
||||
if subtle.ConstantTimeCompare(hashBytes, stored) != 1 {
|
||||
continue
|
||||
}
|
||||
k.LastUsedAt = &now
|
||||
result := *k
|
||||
return &result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid or expired key")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
)
|
||||
|
||||
// ConfigColumnSecurityProvider provides static column security configuration
|
||||
type ConfigColumnSecurityProvider struct {
|
||||
rules map[string][]sectypes.ColumnSecurity
|
||||
}
|
||||
|
||||
func NewConfigColumnSecurityProvider(rules map[string][]sectypes.ColumnSecurity) *ConfigColumnSecurityProvider {
|
||||
return &ConfigColumnSecurityProvider{rules: rules}
|
||||
}
|
||||
|
||||
func (p *ConfigColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]sectypes.ColumnSecurity, error) {
|
||||
key := fmt.Sprintf("%s.%s", schema, table)
|
||||
rules, ok := p.rules[key]
|
||||
if !ok {
|
||||
return []sectypes.ColumnSecurity{}, nil
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ConfigRowSecurityProvider provides static row security configuration
|
||||
type ConfigRowSecurityProvider struct {
|
||||
templates map[string]string
|
||||
blocked map[string]bool
|
||||
}
|
||||
|
||||
func NewConfigRowSecurityProvider(templates map[string]string, blocked map[string]bool) *ConfigRowSecurityProvider {
|
||||
return &ConfigRowSecurityProvider{
|
||||
templates: templates,
|
||||
blocked: blocked,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (sectypes.RowSecurity, error) {
|
||||
key := fmt.Sprintf("%s.%s", schema, table)
|
||||
|
||||
if p.blocked[key] {
|
||||
return sectypes.RowSecurity{
|
||||
Schema: schema,
|
||||
Tablename: table,
|
||||
UserID: userRef,
|
||||
HasBlock: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
template := p.templates[key]
|
||||
return sectypes.RowSecurity{
|
||||
Schema: schema,
|
||||
Tablename: table,
|
||||
UserID: userRef,
|
||||
Template: template,
|
||||
HasBlock: false,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
||||
)
|
||||
|
||||
// Test HeaderAuthenticator
|
||||
func TestHeaderAuthenticator(t *testing.T) {
|
||||
auth := NewHeaderAuthenticator()
|
||||
|
||||
t.Run("successful authentication", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-User-ID", "123")
|
||||
req.Header.Set("X-User-Name", "testuser")
|
||||
req.Header.Set("X-User-Level", "5")
|
||||
req.Header.Set("X-Session-ID", "session123")
|
||||
req.Header.Set("X-Remote-ID", "remote456")
|
||||
req.Header.Set("X-User-Email", "test@example.com")
|
||||
req.Header.Set("X-User-Roles", "admin,user")
|
||||
|
||||
userCtx, err := auth.Authenticate(req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if userCtx.UserID != 123 {
|
||||
t.Errorf("expected UserID 123, got %d", userCtx.UserID)
|
||||
}
|
||||
if userCtx.UserName != "testuser" {
|
||||
t.Errorf("expected UserName testuser, got %s", userCtx.UserName)
|
||||
}
|
||||
if userCtx.UserLevel != 5 {
|
||||
t.Errorf("expected UserLevel 5, got %d", userCtx.UserLevel)
|
||||
}
|
||||
if userCtx.SessionID != "session123" {
|
||||
t.Errorf("expected SessionID session123, got %s", userCtx.SessionID)
|
||||
}
|
||||
if userCtx.Email != "test@example.com" {
|
||||
t.Errorf("expected Email test@example.com, got %s", userCtx.Email)
|
||||
}
|
||||
if len(userCtx.Roles) != 2 {
|
||||
t.Errorf("expected 2 roles, got %d", len(userCtx.Roles))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing user ID header", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-User-Name", "testuser")
|
||||
|
||||
_, err := auth.Authenticate(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when X-User-ID is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid user ID", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-User-ID", "invalid")
|
||||
|
||||
_, err := auth.Authenticate(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error with invalid user ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("login not supported", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := sectypes.LoginRequest{Username: "test", Password: "pass"}
|
||||
|
||||
_, err := auth.Login(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported login")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logout always succeeds", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := sectypes.LogoutRequest{Token: "token", UserID: 1}
|
||||
|
||||
err := auth.Logout(ctx, req)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test ConfigColumnSecurityProvider
|
||||
func TestConfigColumnSecurityProvider(t *testing.T) {
|
||||
rules := map[string][]sectypes.ColumnSecurity{
|
||||
"public.users": {
|
||||
{
|
||||
Schema: "public",
|
||||
Tablename: "users",
|
||||
Path: []string{"email"},
|
||||
Accesstype: "mask",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewConfigColumnSecurityProvider(rules)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("get existing rules", func(t *testing.T) {
|
||||
result, err := provider.GetColumnSecurity(ctx, 1, "public", "users")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 rule, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get non-existent rules returns empty", func(t *testing.T) {
|
||||
result, err := provider.GetColumnSecurity(ctx, 1, "public", "orders")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 rules, got %d", len(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test ConfigRowSecurityProvider
|
||||
func TestConfigRowSecurityProvider(t *testing.T) {
|
||||
templates := map[string]string{
|
||||
"public.orders": "user_id = {UserID}",
|
||||
}
|
||||
blocked := map[string]bool{
|
||||
"public.secrets": true,
|
||||
}
|
||||
|
||||
provider := NewConfigRowSecurityProvider(templates, blocked)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("get template for allowed table", func(t *testing.T) {
|
||||
result, err := provider.GetRowSecurity(ctx, 1, "public", "orders")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.Template != "user_id = {UserID}" {
|
||||
t.Errorf("expected template 'user_id = {UserID}', got %s", result.Template)
|
||||
}
|
||||
if result.HasBlock {
|
||||
t.Error("expected HasBlock to be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get blocked table", func(t *testing.T) {
|
||||
result, err := provider.GetRowSecurity(ctx, 1, "public", "secrets")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !result.HasBlock {
|
||||
t.Error("expected HasBlock to be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get non-existent table returns empty template", func(t *testing.T) {
|
||||
result, err := provider.GetRowSecurity(ctx, 1, "public", "unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.Template != "" {
|
||||
t.Errorf("expected empty template, got %s", result.Template)
|
||||
}
|
||||
if result.HasBlock {
|
||||
t.Error("expected HasBlock to be false")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user