policy

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 12 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 (fix policies.json; it reloads); 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 (policies.json empty) 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 deployment's policy comes from policies.json, never 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 InsertPermissions added in v0.2.0

type InsertPermissions struct {
	AllowColumns []string          `json:"allow_columns,omitempty" yaml:"allow_columns,omitempty"`
	DenyColumns  []string          `json:"deny_columns,omitempty" yaml:"deny_columns,omitempty"`
	Check        map[string]Filter `json:"check,omitempty" yaml:"check,omitempty"`
}

InsertPermissions is a role's write grant on a table: which columns it may set, and the check clauses every inserted record must satisfy. There is no row filter or query limit here — those have no insert-time meaning.

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 ResolvedInsert added in v0.2.0

type ResolvedInsert struct {
	AllowColumns []string
	DenyColumns  []string
	// CheckClauses is column → required value, the form the ingest path
	// enforces and auto-injects from. A []any value marks an _in set.
	CheckClauses map[string]any
}

ResolvedInsert is the write-side state of a resolved grant.

type ResolvedPermissions

type ResolvedPermissions struct {
	Allowed bool
	Select  *ResolvedSelect
	Insert  *ResolvedInsert
}

ResolvedPermissions is the result of evaluating a policy against JWT claims. Allowed is the whole-operation verdict; the resolved per-operation state hangs off Select or Insert, whichever operation Evaluate was called for.

The side Evaluate did NOT resolve is nil. That is the whole point of the pointers: an EMPTY side and an UNRESOLVED side mean opposite things — empty is "no restrictions" (what an admin gets), unresolved is "you asked the wrong question" — and a value type cannot tell them apart, because both are the zero value. Asking a select-resolved grant an insert question would then answer yes to every column of a table the role may not write at all.

Accessors fail closed on a nil side. A BARE FIELD READ panics, which is the intended failure: it is a caller bug, it is loud, and net/http contains it to the one request. Do NOT "fix" such a panic by adding a nil guard that skips the read — an absent WHERE clause is an unfiltered query, so a guard would restore exactly the silent widening this shape exists to prevent. Ask for the operation you actually resolved instead.

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 read-side 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 projectIndices. A nil receiver (no policy) returns cols unchanged.

func (*ResolvedPermissions) CheckClauses

func (rp *ResolvedPermissions) CheckClauses() (map[string]any, bool)

CheckClauses returns the insert side's check clauses, and false when the insert side was never resolved.

It exists because the check-clause loop is the one consumer that iterates a side's map rather than asking a question about a column, so it cannot go through IsColumnAllowed. A bare `perms.Insert.CheckClauses` read presents an empty map on an unresolved side and every check then passes VACUOUSLY — the silent direction. Callers must treat ok=false as "refuse the write", not as "no checks to run".

A nil receiver means no policy applies, so there are no checks to run and ok is TRUE — the same convention as IsColumnAllowed and the other accessors. The fail-closed answer is for the case that actually matters: a grant that WAS resolved, for the other operation. Returning false for a nil receiver would make a deployment with no policy store refuse every ingest.

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.

It answers YES for a denied grant and for one whose read side was never resolved, neither of which has a predicate to speak of. That is deliberate: this is the GATE in front of RowVisible, and a "no filter" answer sends the caller down the deliver-to-the-whole-bucket fast path where RowVisible is never consulted. Saying yes forces the per-subscriber path, where RowVisible denies. Same shape and same reason as RestrictsColumns, which guards the builder's SELECT * expansion.

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, insert bool) bool

IsColumnAllowed checks if a column is permitted by the resolved permissions. insert selects which side of the grant answers: false for a read (query builder, stream projection), true for a write (ingest). A nil receiver means no policy applies — all columns are allowed.

This is the single source of truth for the per-column decision on BOTH sides. Every path defers to it: the query builder checks every column a structured query references against it (internal/query.Build), the stream path's projectIndices drops any event column it rejects, and ingest rejects any record 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 (the select side; the builder is its only caller) — 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 ResolvedSelect added in v0.2.0

type ResolvedSelect struct {
	AllowColumns []string
	DenyColumns  []string
	WhereClause  string
	WhereParams  []any

	AllowedAggregations []string
	DeniedAggregations  []string
	MaxRows             int
	MaxExecutionTime    Millis
	MaxRowsToRead       int64
	MaxMemoryUsage      ByteSize
	// contains filtered or unexported fields
}

ResolvedSelect is the read-side state of a resolved grant.

type RolePermissions

type RolePermissions struct {
	Select *SelectPermissions `json:"select,omitempty" yaml:"select,omitempty"`
	Insert *InsertPermissions `json:"insert,omitempty" yaml:"insert,omitempty"`
}

RolePermissions is one role's grant on one table, split by operation. A nil Select means the role cannot read the table; a nil Insert means it cannot write to it. The two sides carry genuinely different fields — row filters and query limits are read-side, check clauses are write-side — so they are separate types rather than one struct whose halves are inert per operation.

type SelectPermissions added in v0.2.0

type SelectPermissions 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"`
	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"`
}

SelectPermissions is a role's read grant on a table: which columns it may project, which rows it may see, which aggregations it may run, and the resource ceilings its queries run under.

type Source added in v0.2.0

type Source func() *Policy

Source yields the policy a request is evaluated against. It is read per call — never cached by the consumer — so a settings-directory reload applies to the very next request. A nil *Policy from a wired Source is a deliberate lockout (Evaluate denies everything but the operator key); a nil Source means policy filtering is not wired at all, which only tests do. In production the Source is settings.Store.Policy.

func Static added in v0.2.0

func Static(p *Policy) Source

Static returns a Source fixed to p. Intended for tests.

type TablePolicy

type TablePolicy map[string]RolePermissions

TablePolicy defines access control for a single table, keyed by role name. This is the v2 role-first layout: a role appears once per table and states what it may do, instead of appearing under each operation. Matching is exact and case-sensitive; there is no "*" any-role wildcard.

Jump to

Keyboard shortcuts

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