policy

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AdminRole

func AdminRole(p *Policy) string

AdminRole returns the role string that grants full administrative access for a given policy: the configured admin_role (case-sensitive, exact match), or "admin" when a real policy leaves it unset. A nil policy returns "" — with no policy loaded there is no admin role, so a deleted/empty policy can never hand back a usable admin role at the source. This is defense-in-depth behind IsAdmin's own nil guard: recovery from a lost policy is server-side only (restore the policy file and reboot); an "admin" token cannot re-open the deployment over HTTP.

func CanonicalNumericLiteral

func CanonicalNumericLiteral(s string) (string, bool)

CanonicalNumericLiteral renders a policy-authored literal that spells a JSON number in canonical decimal form ("1.0" → "1"), reporting ok=false for everything else. The json.Valid gate keeps this to spellings JSON itself can produce: big.Int would also take "+5" or "007", readings no decoded claim or payload value ever has. It canonicalizes nothing at resolve time — the literal still binds and auto-injects exactly as written; only the check comparison consults this second reading.

func CanonicalScalar

func CanonicalScalar(v any) (string, bool)

CanonicalScalar renders a decoded JSON value as the canonical string the policy layer binds and compares, reporting ok=false for values with no such form: null, objects, and arrays. A structured value is never a sensible scalar comparison value — it usually means a dropped path segment ({{ jwt.app_metadata }} for {{ jwt.app_metadata.tenant_id }}), and binding fmt.Sprint's "map[…]"/"[…]" rendering would let _neq/_lt match essentially every row; the one legitimate structured shape, a bare-claim _in array, is unpacked by resolveInValues before its elements reach here. A json.Number (jwt.WithJSONNumber on claims, UseNumber on ingest payloads) binds in canonical decimal form, not the token's spelling: "1", "1.0", and "1e3" are one JSON value, and a numeric ClickHouse column rejects '1.0'/'1e3' as a per-query TYPE_MISMATCH error. The canonical form is exact at every width and precision — integer literals via big.Int, fractions and exponents via canonicalDecimal, never a float64 round-trip that could bind a value the token doesn't carry ("1e-400" fails closed rather than collapsing to "0"). A literal, or an exact form, past maxCanonicalDigits likewise has no canonical form and fails closed (1e400, 1e-400). Claim resolution and the insert-check comparison's two sides (internal/api) all route through this one function, so what a read filter binds and what a write check accepts can't drift.

func DefaultRoleGrantsAdmin

func DefaultRoleGrantsAdmin(p *Policy) bool

DefaultRoleGrantsAdmin reports whether the policy's default_role resolves to the admin role. When true, ResolveRole maps every roleless request (no token, or a token without a role claim) to the admin role, so unauthenticated callers receive full admin access — including /v1/ops/*. This is permitted as a local/dev convenience (no token needed to exercise admin surfaces), but is unsafe in production, so the policy store warns loudly whenever a policy with this setting is adopted. An empty default_role (no public access) is never admin. This is the single source of truth for the "default grants admin" condition, mirroring IsAdmin's exact, case-sensitive match.

func IsAdmin

func IsAdmin(p *Policy, role string) bool

IsAdmin reports whether role is the privileged admin role. A nil policy (none configured yet, or deleted from KV) admits nobody — not even "admin": "no policy" is a total lockout, so an implicit admin grant can't re-open a deliberately-emptied or lost deployment, and a deployment with a custom admin_role can't have the literal "admin" silently re-privileged. A fresh deployment is bootstrapped from the policy file, not over HTTP. An empty/absent role is never admin either, regardless of how admin_role is configured — a roleless request must never inherit admin via an empty-string match. This is the single source of truth for the admin check; Evaluate, ResolveRole, Validate, the /v1/ops gate, and pipe authorization all route through it.

func ResolveRole

func ResolveRole(p *Policy, role string) string

ResolveRole maps an empty/absent role to the policy's default_role, so a request with no role — a token without a role claim, or no token at all when public access is configured — is evaluated as the configured default. Matching is exact and case-sensitive (no normalization): roles are opaque strings, mirroring the admin check in IsAdmin. An empty default_role (no public access configured) leaves the role empty so the request fails closed. A default_role equal to the admin role IS honored — a roleless request then resolves to admin and receives full access — which is permitted as a local/dev convenience; the store warns loudly when such a policy is adopted (see DefaultRoleGrantsAdmin). A non-empty role is returned unchanged — roles do not inherit the default's permissions.

func RoleAllowed

func RoleAllowed(p *Policy, role string, allowedRoles []string) bool

RoleAllowed reports whether role passes an allowlist gate (used by named pipes). The admin role always passes; otherwise role must appear in allowedRoles by exact, non-empty match — there is no "*" any-role wildcard. An empty/absent role, or an empty allowlist, authorizes nobody but admin (fails closed). Callers must resolve an empty role to the policy default_role (via ResolveRole) before calling if they want default-role access.

func Validate

func Validate(p *Policy) error

Validate checks that a policy is well-formed.

Types

type ByteSize

type ByteSize int64

ByteSize is a byte count. Input accepts a size string ("4GiB", "512MiB", with SI vs IEC distinguished — "4GB" is 4×10^9, "4GiB" is 4×2^30) or a bare integer count of bytes.

func (ByteSize) Bytes

func (b ByteSize) Bytes() int64

Bytes returns the value as a raw byte count.

func (*ByteSize) UnmarshalJSON

func (b *ByteSize) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts a size string or a bare byte count.

func (*ByteSize) UnmarshalYAML

func (b *ByteSize) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML accepts either a string or a bare numeric scalar. A non-scalar node (mapping/sequence) is rejected rather than parsed: its empty Value would read as 0 and silently disable the cap.

type ColumnKind

type ColumnKind uint8

ColumnKind classifies a column's ClickHouse type for the in-memory row-filter comparison. The zero value is ColumnOpaque, so a nil map, a column absent from the map, and a column the schema doesn't know all land on the most conservative class — the three "no type knowledge" states are indistinguishable and equally closed, never a silent downgrade to a laxer comparison.

const (
	// ColumnOpaque: no usable type knowledge (no schema, unknown column) or a type
	// whose text rendering is not canonical — UUID (case), Enum (name vs number),
	// Bool (true vs 1), Date/Date32 (producer spelling), IPv4/IPv6, … For these only
	// byte-equality is trustworthy: identical strings parse to identical ClickHouse
	// values, but differing strings prove nothing. So = and in admit exactly the
	// event's own rendering, while !=, > and < fail closed (the row is withheld).
	ColumnOpaque ColumnKind = iota
	// ColumnNumeric (Int*/UInt*/Float*/Decimal*): both operands render to exact
	// canonical decimal form through the claim side's #457 machinery, then
	// compare in the column's STORAGE domain (ColumnSpec.Numeric): integers at
	// any width exactly, floats after IEEE narrowing to the column's bit width,
	// decimals after truncation to the column's scale — the same narrowing
	// ClickHouse applies to the stored value and the bound constant, so stream
	// and query verdicts agree even on narrowing columns.
	ColumnNumeric
	// ColumnText (String, incl. Nullable/LowCardinality): byte comparison is
	// ClickHouse comparison — equality AND lexicographic order — so every operator
	// is exact. FixedString is NOT ColumnText (zero-padded storage).
	ColumnText
	// ColumnTime (DateTime/DateTime64): operands parse as instants through the
	// caller-supplied ColumnSpec.ParseTime — the same grammar, zone rule, and
	// range guard ingest canonicalization applies — and compare chronologically,
	// so every operator is exact across spellings: a zone-less filter constant
	// matches the canonicalized RFC 3339 payload denoting the same instant. A
	// side that can't be read as a provable instant fails closed.
	ColumnTime
)

type ColumnSpec

type ColumnSpec struct {
	Kind ColumnKind
	// ParseTime converts one rendering of this timestamp column's value — an
	// ingested payload value (string / json.Number / float64) or a resolved
	// filter constant (always a string) — to the instant ClickHouse would store,
	// truncated to the column's precision. ok=false (unparseable, or outside the
	// column type's range, which insert-time saturation would move) fails the
	// comparison closed. Set iff Kind is ColumnTime; the stream supplies it from
	// the schema registry (discovery's Column.TimeParser) so the filter and
	// ingest canonicalization can never disagree on the grammar.
	ParseTime func(v any) (t time.Time, ok bool)
	// Numeric is the column's storage model, set iff Kind is ColumnNumeric
	// (from discovery.NumericStorageOf via the stream's columnSpecs). Its zero
	// value refuses every comparison, so a ColumnNumeric spec built without a
	// model fails closed rather than comparing under the wrong semantics.
	Numeric NumericSpec
}

ColumnSpec is one column's comparison contract for the in-memory row filter: the ColumnKind classification plus the kind's parameters — ColumnTime's instant parser, ColumnNumeric's storage model. The zero value is ColumnOpaque with neither, so a nil map, an absent column, and an unknown type all land on the most conservative class — never a silent downgrade to a laxer comparison.

type Filter

type Filter struct {
	Eq  *string `json:"_eq,omitempty" yaml:"_eq,omitempty"`
	Neq *string `json:"_neq,omitempty" yaml:"_neq,omitempty"`
	Gt  *string `json:"_gt,omitempty" yaml:"_gt,omitempty"`
	Lt  *string `json:"_lt,omitempty" yaml:"_lt,omitempty"`
	In  *string `json:"_in,omitempty" yaml:"_in,omitempty"`
}

Filter represents a single comparison operation.

type LiteralValue

type LiteralValue string

LiteralValue marks an insert-check required value the policy author wrote as a placeholder-free literal (Evaluate). A literal carries no JSON type — "1.0" means the number 1 to a numeric column and the three-character text to a String column — so the check comparison (internal/api) accepts its numeric reading as well as its spelling. The type is the gate: a claim-derived value is never wrapped, so a string-typed claim keeps strict canonical equality and can't gain a numeric reading it didn't have. Only CheckClauses carries this type; read filters bind plain strings.

type Millis

type Millis int64

Millis is a duration stored as whole milliseconds. Input accepts a Go duration string ("10s", "500ms", "1m30s") or a bare integer count of milliseconds.

func (Millis) Duration

func (m Millis) Duration() time.Duration

Duration returns the value as a time.Duration.

func (*Millis) UnmarshalJSON

func (m *Millis) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts a duration string or a bare millisecond count.

func (*Millis) UnmarshalYAML

func (m *Millis) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML accepts either a string or a bare numeric scalar (yaml.v3 hands the scalar's text to us either way). A non-scalar node (mapping/sequence) is rejected rather than parsed: its empty Value would read as 0 and silently disable the cap.

type NumericFamily

type NumericFamily uint8

NumericFamily classifies how a ClickHouse numeric column stores a value — the narrowing the row-filter comparison must apply to BOTH operands so its verdict matches the query path, where ClickHouse narrows the stored value at insert AND the filter constant at compare. The zero value is NumericNone: no storage model, every comparison refused — the same fail-closed zero-value contract as ColumnOpaque, so a future numeric type nobody classified can never be compared under the wrong model.

const (
	NumericNone    NumericFamily = iota // unclassified: refuse, fail closed
	NumericInteger                      // Int*/UInt*: exact at any width
	NumericFloat                        // Float32/Float64: IEEE rounding at Bits
	NumericDecimal                      // Decimal*: truncation at Scale
)

type NumericSpec

type NumericSpec struct {
	Family    NumericFamily
	Bits      int
	Unsigned  bool
	Precision int
	Scale     int
}

NumericSpec is a numeric column's storage model. Bits is the bit width (float width for NumericFloat, integer width for NumericInteger); Unsigned marks UInt* (NumericInteger only); Precision and Scale are the stored total and fractional digit counts (NumericDecimal only, 1 ≤ Precision ≤ 76).

type Policy

type Policy struct {
	// DefaultRole is the role an empty/absent role resolves to (a tokenless or
	// roleless request). Empty means no public access. Setting it equal to
	// AdminRole grants every roleless request full admin — permitted as a
	// local/dev convenience, but the store warns loudly when such a policy is
	// adopted (see DefaultRoleGrantsAdmin); avoid it in production.
	DefaultRole string `json:"default_role" yaml:"default_role"`
	// AdminRole is the role granted full access and the allowlist bypass.
	// Configurable per org (case-sensitive, exact match); defaults to "admin"
	// when unset (see AdminRole()).
	AdminRole string                 `json:"admin_role,omitempty" yaml:"admin_role,omitempty"`
	Tables    map[string]TablePolicy `json:"tables" yaml:"tables"`
}

Policy is the top-level access control configuration.

type ResolvedPermissions

type ResolvedPermissions struct {
	Allowed      bool
	AllowColumns []string
	DenyColumns  []string
	WhereClause  string
	WhereParams  []any

	CheckClauses        map[string]any // column → required value (for inserts)
	AllowedAggregations []string
	DeniedAggregations  []string
	MaxRows             int
	MaxExecutionTime    Millis
	MaxRowsToRead       int64
	MaxMemoryUsage      ByteSize
	// contains filtered or unexported fields
}

ResolvedPermissions is the result of evaluating a policy against JWT claims.

func Evaluate

func Evaluate(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions

Evaluate resolves a policy for a given role, table, and operation against JWT claims.

func (*ResolvedPermissions) AllowedProjection

func (rp *ResolvedPermissions) AllowedProjection(cols []string) []string

AllowedProjection returns the subset of cols this role may read, preserving input order. It is the batch form of IsColumnAllowed and the single source of truth for expanding an unqualified "all columns" read (the SQL the builder would otherwise emit as SELECT *) into the concrete set a role is permitted to see — the projection counterpart to the stream path's filterColumns. A nil receiver (no policy) returns cols unchanged.

func (*ResolvedPermissions) HasRowFilter

func (p *ResolvedPermissions) HasRowFilter() bool

HasRowFilter reports whether this role/table entry carries a row-level-security predicate. The stream fan-out uses it to decide whether an event can be projected once for a whole role bucket (no filter) or must be checked per subscriber against that subscriber's claims (filter present). A nil receiver (no policy applies) has no filter.

func (*ResolvedPermissions) IsAggregationAllowed

func (rp *ResolvedPermissions) IsAggregationAllowed(fn string) bool

IsAggregationAllowed checks if an aggregation function is permitted. A nil receiver means no policy applies — all aggregations are allowed.

func (*ResolvedPermissions) IsColumnAllowed

func (rp *ResolvedPermissions) IsColumnAllowed(col string) bool

IsColumnAllowed checks if a column is permitted by the resolved permissions. A nil receiver means no policy applies — all columns are allowed.

This is the single source of truth for the per-column read decision. Every read path defers to it: the query builder checks every column a structured query references against it (internal/query.Build), and the stream path's filterColumns drops any event field it rejects. Keep it the one decision function so the surfaces can never drift apart.

func (*ResolvedPermissions) RestrictsColumns

func (rp *ResolvedPermissions) RestrictsColumns() bool

RestrictsColumns reports whether this role constrains which columns may be read — whether any column-level allow/deny rule applies. It is false only when the role can read every column (no deny list, and an allow list that is empty or a bare "*" wildcard); there a SELECT * exposes nothing the role isn't already entitled to, so the builder leaves it untouched. When true, the builder must expand SELECT * into AllowedProjection so denied columns never reach the result (#223). A nil receiver (no policy) is unrestricted; a denied role (`Allowed` false) restricts everything. The precedence here mirrors IsColumnAllowed exactly — including the `!Allowed` deny-all — so the "is this role restricted?" and "is this column allowed?" questions can never disagree.

func (*ResolvedPermissions) RowVisible

func (p *ResolvedPermissions) RowVisible(row map[string]any, cols map[string]ColumnSpec) bool

RowVisible reports whether row satisfies every resolved row-filter predicate — the in-memory twin of the query path's WHERE clause, evaluated against a decoded event so the stream applies the same row-level security the query path does. Predicates are ANDed; the query path joins them with AND too.

cols maps column name → ColumnSpec, supplied by the caller from the table schema (see stream.Hub's columnSpecs). Numeric columns compare numerically in the column's storage domain (9 < 100, as ClickHouse would; Float/Decimal operands narrowed the way insert and constant binding narrow them), String columns compare bytewise (exactly ClickHouse's String collation), DateTime/DateTime64 columns compare as instants (both operands parsed through the spec's ParseTime, the same grammar ingest canonicalizes with), and everything else — including every column when no schema is available — admits only byte-equality (= / in) and fails !=, > and < closed: the evaluator cannot mirror ClickHouse's per-type coercion, and text comparison there could admit rows the query path excludes ("9" > "100" as text, an uppercase UUID under !=). Every ambiguous or uncomparable case fails closed — the row is hidden, never leaked — so the boundary costs availability, not confidentiality.

That guarantee is about the INGESTED PAYLOAD value, which is what the stream evaluates; the query path evaluates the stored row. Storage-domain narrowing keeps the two verdicts aligned for values ClickHouse stores; the residual asymmetry is an event whose INSERT later fails entirely (out-of-range value, batch error → DLQ): it was already streamed to whoever the filter admitted, and the row never becomes queryable. Documented in access-control.mdx's enforcement caution.

A nil receiver (no policy applies) makes every row visible.

type RolePermissions

type RolePermissions struct {
	AllowColumns        []string          `json:"allow_columns,omitempty" yaml:"allow_columns,omitempty"`
	DenyColumns         []string          `json:"deny_columns,omitempty" yaml:"deny_columns,omitempty"`
	Filter              map[string]Filter `json:"filter,omitempty" yaml:"filter,omitempty"`
	Check               map[string]Filter `json:"check,omitempty" yaml:"check,omitempty"`
	AllowedAggregations []string          `json:"allowed_aggregations,omitempty" yaml:"allowed_aggregations,omitempty"`
	DeniedAggregations  []string          `json:"denied_aggregations,omitempty" yaml:"denied_aggregations,omitempty"`
	MaxRows             int               `json:"max_rows,omitempty" yaml:"max_rows,omitempty"`
	// MaxExecutionTime, MaxRowsToRead, and MaxMemoryUsage are enforced
	// server-side by ClickHouse (the max_execution_time / max_rows_to_read /
	// max_memory_usage settings, #316), not just as a client-side context
	// deadline. They cap wall-clock time, rows scanned, and peak query memory so
	// a heavy aggregation can't exhaust the box within the time budget.
	// MaxExecutionTime and MaxMemoryUsage are human-readable scalars ("5s",
	// "4GiB") to match clickhouse.query_timeout; MaxRowsToRead is a plain count
	// (int64, since it can exceed 2^31 on a large table).
	MaxExecutionTime Millis   `json:"max_execution_time,omitempty" yaml:"max_execution_time,omitempty"`
	MaxRowsToRead    int64    `json:"max_rows_to_read,omitempty" yaml:"max_rows_to_read,omitempty"`
	MaxMemoryUsage   ByteSize `json:"max_memory_usage,omitempty" yaml:"max_memory_usage,omitempty"`
}

RolePermissions defines what a role can do on a table for a specific operation.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store manages policy persistence via NATS KV with optional file bootstrap.

func NewMemoryStore

func NewMemoryStore(p *Policy) *Store

NewMemoryStore creates an in-memory policy store pre-loaded with the given policy. Intended for testing — no NATS connection required.

func NewStore

func NewStore(ctx context.Context, js jetstream.JetStream, bootstrapPath string, logger *slog.Logger) (*Store, error)

NewStore creates a policy store backed by NATS KV.

Bootstrap semantics:

  • If KV already holds a policy, it wins — the file is a seed, not the source of truth, so subsequent runs ignore bootstrapPath entirely. Runtime updates flow in via Put and KV Watch.
  • If KV is empty and bootstrapPath is set, the file MUST exist, parse, validate, and persist; any failure is fatal so a misconfigured deployment refuses to start instead of silently running fail-closed (every request denied, including the admin role) until an operator notices.
  • If KV is empty and bootstrapPath is "", the store starts with no policy and the operator must seed via Put — every request fails closed in the meantime, which we log loudly.

func (*Store) Get

func (s *Store) Get() *Policy

Get returns the current cached policy. May be nil if no policy is set.

func (*Store) Put

func (s *Store) Put(ctx context.Context, p *Policy) error

Put saves a policy to the NATS KV store and updates the local cache.

func (*Store) Watch

func (s *Store) Watch(ctx context.Context)

Watch subscribes to policy changes in the NATS KV store so all nodes stay in sync. Blocks until ctx is cancelled.

type TablePolicy

type TablePolicy struct {
	Select map[string]RolePermissions `json:"select,omitempty" yaml:"select,omitempty"`
	Insert map[string]RolePermissions `json:"insert,omitempty" yaml:"insert,omitempty"`
}

TablePolicy defines access control for a single table.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL