Documentation
¶
Overview ¶
Package joinexec is the security boundary for joined reads.
DALgo evaluates a joined query by reading each source with a single-source query and combining the rows itself. Every one of those reads passes through a leaf made by a Guard, and the leaf is the one place that
- authorises the collection before it is read (a denied collection is never read, and never has its schema looked up),
- refuses anything that is not a plain query over one collection,
- counts rows and encoded bytes against the request budget while streaming, and stops with a BudgetError when the budget is exceeded,
- records per-source statistics for the response.
One Guard serves one request. Create it with NewGuard, take its context from Guard.Context, make a leaf per source with Guard.Leaf, hand the leaves to DALgo (for example through a dal.DatabaseResolver), and answer the caller with Guard.Collect, which drains the reader DALgo returns, closes it and returns either every row or no rows and a classified error. A caller that reads the reader itself must read it to the end, close it, and only then pass the error it ended with (or nil) to Guard.Classify; rows read before an error are not an answer, because DALgo's streaming join delivers its rows before a budget error arrives.
Index ¶
- Constants
- Variables
- func IsJoinPlanRefusal(message string) bool
- func MapDalgoError(err error, route string) error
- type Admit
- type Authorize
- type BudgetError
- type CapacityError
- type EngineNotJoinableError
- type EngineNotQueryableError
- type Execution
- type ExecutionSource
- type Guard
- func (g *Guard) Classify(err error, route string) error
- func (g *Guard) Collect(ctx context.Context, reader dal.RecordsReader, route string) ([]record.Record, error)
- func (g *Guard) Context(parent context.Context) (context.Context, context.CancelFunc)
- func (g *Guard) Err() error
- func (g *Guard) Leaf(src Source) dal.QueryExecutor
- func (g *Guard) Stats() []SourceStats
- type Limits
- type Option
- type Profile
- type ProfileSource
- type Registry
- type Result
- type Source
- type SourceDeniedError
- type SourceError
- type SourceStats
- type UnknownDatabaseError
Constants ¶
const ( MaxInMemoryJoinRows = dalgoJoinRows MaxInMemoryJoinBytes = dalgoJoinBytes MaxInMemoryGroups = dalgoAggregationGroups )
The bounds of the in-memory route, which the server states to a client in its discovery document and in the answer to a request that reaches one: the most rows and bytes a join holds, and the most groups an aggregation keeps.
const ( RouteInMemory = "in-memory" RouteDatabase = "database" )
Execution routes a BudgetError can name. They are the same labels the HTTP response reports as execution.route.
const ( BudgetSourceRows = "source_rows" BudgetSourceBytes = "source_bytes" BudgetJoinRows = "join_rows" BudgetJoinResultRows = "join_result_rows" BudgetJoinFetchedRows = "join_fetched_rows" BudgetJoinRetainedBytes = "join_retained_bytes" BudgetJoinScan = "join_scan" BudgetJoinCandidateEvaluations = "join_candidate_evaluations" BudgetAggregationGroups = "aggregation_groups" BudgetAggregationStates = "aggregation_states" BudgetAggregationBytes = "aggregation_bytes" BudgetAggregationDistinctValues = "aggregation_distinct_values" BudgetAggregationTotalDistinct = "aggregation_total_distinct_values" )
Budget names carried by BudgetError.Name. The source_* names are this package's own request budget; the join_* and aggregation_* names are the bounds DALgo enforces itself (see MapDalgoError).
const ( // MaxResultRows is the most rows a result may have. MaxResultRows = 1000 // MaxResultBytes is the most JSON-encoded bytes a result may have. MaxResultBytes = 8 << 20 )
The result of one request is bounded, whatever the sources hold: a result over either bound is refused with a BudgetError, never cut short.
const ( BudgetResponseRows = "response_rows" BudgetResponseBytes = "response_bytes" )
Budget names of the result bound, carried by BudgetError.Name beside the ones of errors.go. They bound what is answered, not what is read.
Variables ¶
var ( // ErrNotSingleSource is returned for a read that is not a plain structured // query over exactly one collection: a join, a derived source, a query // holding a subquery, or a text query. Only single-source reads reach a // source, because only those can be authorised one collection at a time. ErrNotSingleSource = errors.New("joinexec: a source read must be a plain query over one collection") // ErrUnsupportedSource is returned for a schema-qualified collection on a // source that does not explicitly support native PostgreSQL reads, or a // collection nested under a parent key. Native PostgreSQL refs are converted // to the same logical collection IDs used by request profiles and grants. ErrUnsupportedSource = errors.New("joinexec: schema-qualified and nested collections are not supported") // ErrRecordsetUnsupported is returned by the recordset read path, which is // not guarded and therefore not offered. ErrRecordsetUnsupported = errors.New("joinexec: recordset readers are not supported") // ErrNoExecutor is returned when a Source has no executor to read through. ErrNoExecutor = errors.New("joinexec: source has no executor") // ErrRowNotEncodable is returned when a row cannot be encoded to measure it // against the byte budget. The read fails closed. ErrRowNotEncodable = errors.New("joinexec: source row cannot be encoded") )
var ( // ErrInvalidDocument is returned when the document is not one this package // executes: a shape the walk does not know, a name that is not plain, or a // profile that does not describe the query. The document was not run, and no // mount was resolved for it. ErrInvalidDocument = errors.New("joinexec: invalid document") // ErrProfileMismatch is returned, wrapped in ErrInvalidDocument as well, when // the Profile handed to Execute does not describe the query the walk found: // another set of sources or another answer about subqueries. It tells a // disagreement between the classifier that built the profile and the walk (a // defect of the server) from a document that is merely invalid. ErrProfileMismatch = errors.New("joinexec: the profile does not match the query") // ErrSourceWithoutDatabase is returned when a source names no database and // there is no single default one to read it from: the request has no default // database, or the document reads several databases and so must name the // database of every source. ErrSourceWithoutDatabase = errors.New("joinexec: a source names no database") // ErrScanOnProtectedSource is returned for a scan clause on a source with // access policies. DALgo applies the scan's limit and order to the source's // own read, where the policy filter could run after the limit, so the clause // is refused until DALgo applies policies before scan bounds (dalgo pull // request 197). ErrScanOnProtectedSource = errors.New("joinexec: scan clauses are not supported on a source with access policies") // ErrReadTxSkipped is returned when a source's ReadTx returned without // running the function it was given, which would otherwise read as an empty // result. ErrReadTxSkipped = errors.New("joinexec: the read transaction did not run the query") // ErrNoReader is returned when an executor returned neither a reader nor an // error. ErrNoReader = errors.New("joinexec: the executor returned no reader") // ErrReadTruncated is returned when the reader of the database route failed // with an error that wraps io.EOF. A reader's end is a bare io.EOF; an error // that merely wraps it is a failure part way (a dropped connection reports // itself this way), and answering it as the end would return the rows read // so far as a complete result. The original error is in the message, not in // the chain, so that nothing reads it as the end of the stream. ErrReadTruncated = errors.New("joinexec: the read ended with a failure that looks like the end of the stream") // ErrRegistryMismatch is returned when the registry answers a database id // with a source of another id. The leaf authorises a read under the source's // own id, so such a source would be authorised under the wrong name. ErrRegistryMismatch = errors.New("joinexec: the registry returned a source for another database") )
Functions ¶
func IsJoinPlanRefusal ¶ added in v0.12.3
IsJoinPlanRefusal reports whether message, the Message of a join_plan error of DALgo's join, is the refusal of a document a caller made and can change: a wildcard over a source with no field list, a cursor, an array that is not one, a null test of nothing, an operator or a type of expression or condition the join does not evaluate. Any other message of the category is a failed read, a fault of encoding or a bound.
func MapDalgoError ¶
MapDalgoError returns a *BudgetError when err is, or wraps, one of DALgo's own bound errors, and err unchanged otherwise (including nil). route is the route the engine ran on (RouteInMemory or RouteDatabase). An error that is already a *BudgetError or *SourceDeniedError is returned as is.
DALgo reports a leaf's error as text inside its own error (and, since dalgo v0.89.6, as the cause of a join_plan diagnostic); use Guard.Classify to recover a leaf's BudgetError, SourceDeniedError or SourceError as well.
Types ¶
type Admit ¶ added in v0.11.14
Admit takes a slot for a request that is about to read, on the route Execute chose for it (RouteDatabase or RouteInMemory). It returns the function that gives the slot back and true, or false when there is no slot, in which case the release function is not used. Execute calls the release function when the request ends, whatever way it ends; a nil release function is treated as one that does nothing.
type Authorize ¶
Authorize reports whether the caller may read the collection of the database. It is called before every read; a nil Authorize denies everything.
type BudgetError ¶
type BudgetError struct {
// Name is one of the Budget* constants.
Name string
// Limit is the bound that was exceeded. It is 0 when DALgo's message does
// not identify which of several limits applied.
Limit int64
// Route is RouteInMemory or RouteDatabase.
Route string
// Path locates the bound: "database.collection" for a source read, or the
// join tree path DALgo reports (for example "from.joins[0]"). It can be
// empty when DALgo gives no path.
Path string
}
BudgetError reports that a request exceeded a resource bound. It names the bound and its limit, never the observed figure.
A BudgetError ends the request, but rows may already have been read when it arrives: DALgo's streaming join (a flat equality join without ORDER BY) returns its reader first and the error comes from a later Next. Only a caller that reads to the end before answering returns no rows; Guard.Collect does exactly that.
func (*BudgetError) Error ¶
func (e *BudgetError) Error() string
type CapacityError ¶ added in v0.11.14
type CapacityError struct {
// Route is RouteInMemory or RouteDatabase.
Route string
}
CapacityError reports that no slot was free for a request on its route: the server is running as many queries of that kind as it allows. Nothing was read.
func (*CapacityError) Error ¶ added in v0.11.14
func (e *CapacityError) Error() string
type EngineNotJoinableError ¶ added in v0.11.11
EngineNotJoinableError reports a database whose storage engine is not in the configured set of engines that may take part in relational queries.
func (*EngineNotJoinableError) Error ¶ added in v0.11.11
func (e *EngineNotJoinableError) Error() string
type EngineNotQueryableError ¶ added in v0.11.11
EngineNotQueryableError reports a database whose storage engine cannot run structured queries at all. Callers answer it as the engine guard of the single-collection path does (HTTP 501 query_unsupported). It takes precedence over EngineNotJoinableError, and no operator setting lifts it.
func (*EngineNotQueryableError) Error ¶ added in v0.11.11
func (e *EngineNotQueryableError) Error() string
type Execution ¶ added in v0.11.11
type Execution struct {
// Route is RouteDatabase or RouteInMemory. It names the path, not where every
// filter ran: on RouteInMemory DALgo evaluates the document above guarded
// single-collection reads, and a document with one source that names its
// database and has no subquery, null test, aggregation or scan clause is read
// with one such query that carries its WHERE, ORDER BY and LIMIT, which the
// mount's own executor runs. Every other in-memory document (one with a join,
// GROUP BY, HAVING, an aggregate, a scan clause, or a source that names no
// database) is read with plain scans of each collection and filtered above
// them.
Route string `json:"route"`
ElapsedMs int64 `json:"elapsedMs"`
RowsReturned int `json:"rowsReturned"`
Sources []ExecutionSource `json:"sources"`
}
Execution describes how a request ran. It is the execution block of the response and never reports how many rows a source holds or was read for, only how many rows it delivered (and not even that for a protected source).
type ExecutionSource ¶ added in v0.11.11
type ExecutionSource struct {
Database string `json:"database"`
Collection string `json:"collection"`
// Rows is how many rows the source delivered. It is nil for a source with
// access policies, whose size a caller may not learn, and on the database
// route, where the database ran the whole document and reports no more.
Rows *int `json:"rows,omitempty"`
// ElapsedMs is the time the source's readers were open, nil on the database
// route.
ElapsedMs *int64 `json:"elapsedMs,omitempty"`
}
ExecutionSource describes the reads of one collection.
type Guard ¶
type Guard struct {
// contains filtered or unexported fields
}
Guard holds the budget, statistics and first failure of one request. It is safe for concurrent use by the leaves it made.
func NewGuard ¶
NewGuard returns the Guard for one request. Zero fields of limits take their defaults (see Limits).
func (*Guard) Classify ¶
Classify turns what a request ended with into the error to answer with. err is the error DALgo returned, or the one a drained reader ended with, or nil.
DALgo reports a leaf's error as text inside its own error (and reads an error that wraps io.EOF as the end of a stream), so the recorded failure wins when there is one, even for a nil err: a request that recorded a failure never ends well. (Since dalgo v0.89.6 the diagnostic also carries the leaf's error as its cause; the answer is still the recorded error, never DALgo's wrapper.) Otherwise DALgo's own bounds map to BudgetError (see MapDalgoError) and any other error is returned unchanged. Call it once, after the reader was drained and closed.
func (*Guard) Collect ¶
func (g *Guard) Collect(ctx context.Context, reader dal.RecordsReader, route string) ([]record.Record, error)
Collect drains reader, closes it and returns every row, or no rows and the classified error when the read failed in any way: an error from the reader, a failure the guard recorded even if the reader ended cleanly, a close error, or the end of ctx. It is the one way to answer a request that cannot return a partial result. reader must be non-nil, so a caller whose DALgo call returned an error answers with Classify instead.
func (*Guard) Context ¶
Context returns parent bounded by the request timeout. The caller must call the returned cancel function.
func (*Guard) Err ¶
Err returns the first failure a leaf recorded (a SourceDeniedError, a BudgetError, a SourceError carrying the source's own error, or a refusal such as ErrNotSingleSource), or nil. Once set, every further read through any leaf of this Guard fails with it.
func (*Guard) Leaf ¶
func (g *Guard) Leaf(src Source) dal.QueryExecutor
Leaf returns the guarded executor for one source. It implements dal.QueryExecutor and dal.JoinFieldsProvider.
func (*Guard) Stats ¶
func (g *Guard) Stats() []SourceStats
Stats returns the per-collection statistics in the order collections were first read. It is complete only after every reader has been closed; Collect closes the reader it drains.
type Limits ¶
type Limits struct {
// MaxSourceRows caps the rows read from all sources together.
MaxSourceRows int
// MaxSourceBytes caps the JSON-encoded bytes read from all sources together.
MaxSourceBytes int64
// Timeout bounds the whole request; see Guard.Context.
Timeout time.Duration
}
Limits bounds one request. A zero or negative field takes its default from DefaultLimits, so an unconfigured server is bounded rather than unbounded.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits are the request bounds for a 512 MiB instance.
type Option ¶ added in v0.11.11
type Option func(*config)
Option configures Execute.
func WithAdmission ¶ added in v0.11.14
WithAdmission makes Execute ask admit for a slot once it knows the route and before it reads anything, and hold the slot until it returns. A request that fails earlier (a document that is not valid, a source the caller may not read, an unknown database, an engine that is refused) holds no slot and asks for none. When admit has no slot Execute returns a *CapacityError and has read nothing. The wait for a slot is part of the elapsed time Execute reports, and not of the request timeout, which starts when the read does. A nil admit admits every request.
func WithJoinEngines ¶ added in v0.11.11
WithJoinEngines sets the storage engines whose databases may take part in a relational query. The default is sqlite and ingitdb, as the server's default query limits; an empty list keeps the default. An engine outside the set is refused with an EngineNotJoinableError.
func WithNativeEngines ¶ added in v0.11.11
WithNativeEngines sets the storage engines that run a whole document themselves (the database route). The default is sqlite; an empty list keeps the default. An engine listed here must also be in the join set.
func WithNativePostgresFields ¶ added in v0.21.0
func WithNativePostgresFields() Option
WithNativePostgresFields allows exact quoted field names for a native PostgreSQL read-only endpoint. The source's CheckRead and field-list guards still require each field to exist in that mounted catalog. Callers must only set this option when every source belongs to that native PostgreSQL mount.
type Profile ¶ added in v0.11.11
type Profile struct {
// Sources lists every collection the document reads, including reads inside
// derived sources and subquery expressions; a collection read twice is
// listed twice. Database is empty for a source that names none.
Sources []ProfileSource
// HasSubquery reports a derived source, scalar subquery, EXISTS predicate
// or query-valued comparison operand anywhere in the document.
HasSubquery bool
}
Profile is the classification of the document, as the profile classifier of pkg/core reports it. It is a claim, not evidence: Execute walks the query itself and refuses a profile that does not describe it (ErrInvalidDocument).
type ProfileSource ¶ added in v0.11.11
ProfileSource is one collection read of a Profile.
type Registry ¶ added in v0.11.11
type Registry interface {
// Lookup returns the source registered under database, or false when there
// is none.
Lookup(database string) (Source, bool)
}
Registry resolves a database id to the source registered under it. A server that keeps a mount alive for the length of a request takes that hold in Lookup and lets go of it when the request ends.
type Result ¶ added in v0.11.11
type Result struct {
Records []record.Record
// Columns names the columns of the records in the order they were selected.
Columns []string
Execution Execution
}
Result is the answer to one request.
func Execute ¶ added in v0.11.11
func Execute(ctx context.Context, query dal.StructuredQuery, profile Profile, defaultDatabase string, registry Registry, authorize Authorize, limits Limits, opts ...Option) (Result, error)
Execute runs a relational document and returns every row of its result, or no rows and an error. It is the one place that reads a joined result, and it reads with Guard.Collect, so a request that ran out of budget or lost a source part way is never answered with the rows read before the failure.
defaultDatabase is the database of a source that names none; it is empty on an endpoint that serves several databases, where every source must name one. A source with a database of its own is read from that database whatever the default is: an endpoint that serves one database must refuse a document that names another itself.
Execute works in this order, and a request that fails at one step has done nothing of the steps after it:
- Walk the document, check every name in it and check the profile against the walk (ErrInvalidDocument).
- Settle the database of every source (ErrSourceWithoutDatabase).
- Authorise every source the walk found (SourceDeniedError), before any mount is resolved, so a caller learns nothing about databases it may not read: 403 before 404.
- Resolve each database once (UnknownDatabaseError).
- Refuse an engine that cannot be queried (EngineNotQueryableError), for every source, and then one outside the join set (EngineNotJoinableError).
- Refuse a scan clause on a source with access policies (ErrScanOnProtectedSource).
- Choose the route, take a slot for it when WithAdmission is set (CapacityError), and run. The retry in memory takes a slot of its own.
The database route is chosen when every source is in one database, that database's engine is native, it has no access policies, the document has no subquery and no null test, and no source has a scan clause (no engine adapter applies a scan bound or compiles a null test). The whole document then runs in one read transaction of the database. A join with no aggregation that the database answers with an enforcement-unsupported denial (its adapter cannot compile the document, and says so before any row is read) is read again in memory, after the first slot is given back; see request.retryInMemory. Every other document runs in memory: DALgo reads each source with a single-collection query through a guarded leaf and joins, filters and aggregates above the leaves, so a source with access policies is only ever read through its policy-checked executor and only rows the caller may read reach a join or an aggregate. See Execution.Route for what the in-memory label does and does not say about where a filter ran.
A Source must answer CanQuery, which says whether its engine can run structured queries at all (core.Database has it). A Source without the method is treated as one that cannot be queried, so the gate does not depend on the method set of whatever type a registry returns. The check comes before the join-set check and no join-set setting lifts it.
A Source may also answer CheckRead(dal.Query) error (core.Database has it): the checks a read through the source runs before its driver is reached, asked without a transaction. The database route asks it before ReadTx, so a document the source refuses begins no transaction; a Source without the method is refused only inside its transaction, by whatever guards its executor.
The walk checks every name of a document (fields, aliases, qualifiers, parameters, the result names of scalar subqueries and collections) by the strict rules of pkg/core, the arithmetic operators and aggregate names by the classifier's lists, refuses a query whose columns carry one output name twice (the later column would replace the earlier one in a row), refuses a document that still holds a parameter (a parameter is bound before a document runs, and DALgo's join evaluates none), and refuses a document whose conditions and expressions nest more than 64 levels. A field name must pass the strict rule (core.ValidateFieldName), except when WithNativePostgresFields is used for a single native PostgreSQL endpoint. That opt-in admits the same bounded quoted names as the source's exact catalog guard; other routes remain strict. What only the classifier checks, and Execute does not repeat, is the format of a database id, the limit and offset bounds, money, cursors, the join types, the number of sources, how many levels of subquery nest (four), the refusal of every scan clause (Execute accepts one on a source without access policies and reads such a document in memory), and the sixteen levels at which its name check stops. A caller must therefore hand Execute a document, and a profile, that passed the classifier.
opts, limits and the ordering of results are as documented on Option, Limits, MaxResultRows and MaxResultBytes.
type Source ¶
type Source interface {
// ID is the database id the source is registered under. It is the database
// name Authorize receives and the name a query's collections must carry.
ID() string
// Engine is the storage engine name, for routing decisions made by the
// caller.
Engine() string
// HasAccessPolicies reports whether Executor is wrapped in access
// policies. Statistics of such a source are marked Protected.
HasAccessPolicies() bool
// Executor reads the source with the request principal applied. Every read
// of the in-memory route goes through it.
Executor() dal.QueryExecutor
// ReadTx runs fn in one read transaction on the source, for the database
// route. Reads made through the executor passed to fn are not guarded by a
// leaf; the caller authorises such a query before running it.
ReadTx(ctx context.Context, fn func(dal.QueryExecutor) error) error
}
Source is one registered database as the join executor sees it. The server's mount type implements it; this package depends on nothing else of the server.
type SourceDeniedError ¶
SourceDeniedError reports a read of a collection the caller may not read. The collection was never read.
func (*SourceDeniedError) Error ¶
func (e *SourceDeniedError) Error() string
type SourceError ¶
SourceError reports that a source failed while it was being read: its executor returned an error, a reader failed part way, or the request context ended during a read. Err is the source's own error with its chain intact, so errors.Is finds an access denial, a deadline or a backend sentinel through it. DALgo rewraps such an error as text; the guard records this one so that Guard.Classify can return it instead.
func (*SourceError) Error ¶
func (e *SourceError) Error() string
func (*SourceError) Unwrap ¶
func (e *SourceError) Unwrap() error
type SourceStats ¶
type SourceStats struct {
Database string
Collection string
// Rows is the number of rows delivered to the executor.
Rows int
// Elapsed is the time readers of this collection were open, summed over
// reads.
Elapsed time.Duration
// Protected is true when the source has access policies. Callers should
// not report Rows for a protected source.
Protected bool
}
SourceStats describes the reads of one collection during a request.
type UnknownDatabaseError ¶ added in v0.11.11
type UnknownDatabaseError struct {
Database string
}
UnknownDatabaseError reports a database id no source is registered under. It is returned only after every source of the document was authorised, so it does not tell a caller who may not read a database whether it exists.
func (*UnknownDatabaseError) Error ¶ added in v0.11.11
func (e *UnknownDatabaseError) Error() string