mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
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
201 lines
6.1 KiB
Go
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
|
|
}
|