Files
ResolveSpec/pkg/security/lookup/direct/policy.go
T
Hein c9fa8c60f2 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
2026-10-01 13:19:44 +02:00

201 lines
6.1 KiB
Go

package direct
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
)
// PolicyOptions tunes Policy.
type PolicyOptions struct {
// NoGroups skips the group membership table: only rules addressed to the user directly
// apply. Use it when the sec_group_members table is not deployed.
NoGroups bool
}
// Policy implements lookup.PolicyStore on the rule tables.
//
// Applicable rules are the active rules whose user_id is the caller plus the rules of every
// group the caller belongs to; schema and table match case-insensitively and exactly (never a
// prefix). Column security returns the union of the matching rules. Row security: any
// applicable has_block rule wins, otherwise the templates are combined with AND, each in
// parentheses. No rule is an empty result; failures are errors so callers fail closed.
type Policy struct {
*Base
opts PolicyOptions
}
var _ lookup.PolicyStore = (*Policy)(nil)
// NewPolicy creates the direct PolicyStore.
func NewPolicy(b *Base, opts PolicyOptions) *Policy { return &Policy{Base: b, opts: opts} }
// applicable restricts a rule query to the rules that apply to userID.
func (p *Policy) applicable(userCol, groupCol lookup.Column, userID int64) Cond {
if p.opts.NoGroups {
return Eq(userCol, userID)
}
members := p.From(lookup.EntitySecGroupMembers).Cols(lookup.GroupMembersGroupID).Where(Eq(lookup.GroupMembersUserID, userID))
return Or(Eq(userCol, userID), InSelect(groupCol, members))
}
// ColumnSecurity implements lookup.PolicyStore.
func (p *Policy) ColumnSecurity(ctx context.Context, userID int, schema, table string) ([]sectypes.ColumnSecurity, error) {
var rules []sectypes.ColumnSecurity
err := p.do(func(q Querier) error {
rules = nil
rows, err := p.From(lookup.EntitySecColumnRules).
Cols(lookup.ColRulesID, lookup.ColRulesColumnPath, lookup.ColRulesAccessType, lookup.ColRulesMaskStart,
lookup.ColRulesMaskEnd, lookup.ColRulesMaskInvert, lookup.ColRulesMaskChar, lookup.ColRulesExtraFilters).
Where(
Eq(lookup.ColRulesIsActive, true),
EqFold(lookup.ColRulesSchemaName, schema),
EqFold(lookup.ColRulesTableName, table),
p.applicable(lookup.ColRulesUserID, lookup.ColRulesGroupID, int64(userID)),
).OrderBy(lookup.ColRulesID).Query(ctx, q)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id int
var path, access string
var start, end sql.NullInt64
var invert sql.NullBool
var maskChar sql.NullString
var extra any
var inv any
if err := rows.Scan(&id, &path, &access, &start, &end, &inv, &maskChar, &extra); err != nil {
return err
}
if inv != nil {
b, err := p.d.ScanBool(inv)
if err != nil {
return err
}
invert = sql.NullBool{Bool: b, Valid: true}
}
rule := sectypes.ColumnSecurity{
ID: id,
Schema: schema,
Tablename: table,
Path: strings.Split(path, "."),
Accesstype: access,
UserID: userID,
MaskStart: int(start.Int64),
MaskEnd: int(end.Int64),
MaskInvert: invert.Bool,
MaskChar: "*",
Control: schema + "." + table + "." + path,
}
if maskChar.Valid && maskChar.String != "" {
rule.MaskChar = maskChar.String
}
if err := p.d.DecodeJSON(extra, &rule.ExtraFilters); err != nil {
return err
}
rules = append(rules, rule)
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("failed to load column security: %w", err)
}
return rules, nil
}
// numericUser reduces a user reference to the integer the rule tables key on. Structured
// values are rejected, and so are non-numeric strings: a reference that cannot be matched
// must fail closed rather than silently load no rules.
func numericUser(ref any) (int64, error) {
switch v := ref.(type) {
case *sectypes.UserContext:
if v == nil {
return 0, fmt.Errorf("row security: nil user context")
}
return int64(v.UserID), nil
case sectypes.UserContext:
return int64(v.UserID), nil
case int:
return int64(v), nil
case int8:
return int64(v), nil
case int16:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
case uint:
return int64(v), nil //nolint:gosec // user ids fit int64
case uint8:
return int64(v), nil
case uint16:
return int64(v), nil
case uint32:
return int64(v), nil
case uint64:
return int64(v), nil //nolint:gosec // user ids fit int64
case string:
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, fmt.Errorf("row security: user reference %q is not a numeric id", v)
}
return n, nil
case nil:
return 0, fmt.Errorf("row security: no user reference")
}
return 0, fmt.Errorf("row security: unsupported user reference type %T", ref)
}
// RowSecurity implements lookup.PolicyStore.
func (p *Policy) RowSecurity(ctx context.Context, userRef any, schema, table string) (sectypes.RowSecurity, error) {
uid, err := numericUser(userRef)
if err != nil {
return sectypes.RowSecurity{}, err
}
var templates []string
block := false
err = p.do(func(q Querier) error {
templates, block = nil, false
rows, err := p.From(lookup.EntitySecRowRules).Cols(lookup.RowRulesTemplate, lookup.RowRulesHasBlock).
Where(
Eq(lookup.RowRulesIsActive, true),
EqFold(lookup.RowRulesSchemaName, schema),
EqFold(lookup.RowRulesTableName, table),
p.applicable(lookup.RowRulesUserID, lookup.RowRulesGroupID, uid),
).OrderBy(lookup.RowRulesID).Query(ctx, q)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var tpl sql.NullString
var hb bool
if err := rows.Scan(&tpl, p.boolDest(&hb)); err != nil {
return err
}
if hb {
block = true
}
if t := strings.TrimSpace(tpl.String); t != "" {
templates = append(templates, "("+t+")")
}
}
return rows.Err()
})
if err != nil {
return sectypes.RowSecurity{}, fmt.Errorf("failed to load row security: %w", err)
}
rs := sectypes.RowSecurity{Schema: schema, Tablename: table, UserID: userRef, HasBlock: block}
if !block {
rs.Template = strings.Join(templates, " AND ")
}
return rs, nil
}