Documentation
¶
Index ¶
- func AdminRole(p *Policy) string
- func CanonicalNumericLiteral(s string) (string, bool)
- func CanonicalScalar(v any) (string, bool)
- func DefaultRoleGrantsAdmin(p *Policy) bool
- func IsAdmin(p *Policy, role string) bool
- func ResolveRole(p *Policy, role string) string
- func RoleAllowed(p *Policy, role string, allowedRoles []string) bool
- func Validate(p *Policy) error
- type ByteSize
- type ColumnKind
- type ColumnSpec
- type Filter
- type LiteralValue
- type Millis
- type NumericFamily
- type NumericSpec
- type Policy
- type ResolvedPermissions
- func (rp *ResolvedPermissions) AllowedProjection(cols []string) []string
- func (p *ResolvedPermissions) HasRowFilter() bool
- func (rp *ResolvedPermissions) IsAggregationAllowed(fn string) bool
- func (rp *ResolvedPermissions) IsColumnAllowed(col string) bool
- func (rp *ResolvedPermissions) RestrictsColumns() bool
- func (p *ResolvedPermissions) RowVisible(row map[string]any, cols map[string]ColumnSpec) bool
- type RolePermissions
- type Store
- type TablePolicy
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AdminRole ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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) UnmarshalJSON ¶
UnmarshalJSON accepts a size string or a bare byte count.
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) UnmarshalJSON ¶
UnmarshalJSON accepts a duration string or a bare millisecond count.
func (*Millis) UnmarshalYAML ¶
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 ¶
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.
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.