Files
Julia Ogris 2c92419557 appresource: Compile and evaluate where clauses (#68796)
* appresource: Compile and evaluate where clauses

Create the leaf package `lib/appresource` with the predicate language
for HTTP app access. Carry over the where environment from the sketch
branch `julia/app/policy-matcher-sketch`. That environment covers the
`user.name`, `user.roles`, `user.traits`, and `request.method`
bindings, the `true` and `false` constants, and the list and string
helpers (`set`, `contains`, `lower`, `upper`, `has_prefix`,
`has_suffix`, `has_substring`).

Export `CompileWhere` and `Where.Evaluate` as the entry point, so a
caller can compile a where clause and match it against a request and a
caller identity. Keep the parser unexported. Name the function for the
clause it compiles, because a follow-up PR adds a second entry point
for the desugared expression form. Pass the evaluation environment as
one `Env` struct, so the `vars.` capture namespace can be added as a new
field without changing the `Evaluate` signature.

Validate the environment before evaluation and reject any method
outside the canonical upper-case HTTP method list, so a case-varied
method can neither slip past a negated comparison nor match a clause
while the backend receives a different spelling.

Aggregate `trace.BadParameter` with the parse error in `CompileWhere`,
matching the sibling path tokenizer, so a caller can tell a malformed
clause in a role from an internal fault without losing the parse error
chain. Report no match when evaluation fails, so a caller that drops
the error denies rather than allows.

Bind `true` and `false` explicitly, because `typical` has no boolean
literal, so a bare literal would otherwise fail as an unknown
identifier.

Leave the `allow_code` and `deny_hint` audit wrappers to the PR that
adds the rule types. Both wrappers record against the rule that
matched, and checking that a wrapper sits where its record can be
attributed correctly needs the rule types to define what one rule's
expression is. Compile clauses over identity and method only. Leave
path matching and the `vars.` capture namespace to follow-up PRs as
well.

* appresource: Use net/http method constants

Replace the hand-written method strings in `validMethods` with the
`net/http` constants, so the list pins the canonical spellings instead
of repeating them by hand.

* appresource: Add property-based where clause tests

Add rapid property tests over generated clauses and environments. The
properties are that `CompileWhere` never panics on arbitrary input,
that a clause and its negation never agree, that any method outside
the canonical list is denied for every clause, that `contains` over
`set` matches `slices.Contains`, and that evaluation is
deterministic.

* appresource: Address review comments

Add `NewEnv` so the canonical HTTP method check runs once at the request
boundary. A `where` clause is optional on a rule, so a rule with only
`methods` and `paths` never builds a `Where` and never reached the check,
which meant adding a no-op `where: "true"` to an otherwise identical rule
changed the answer for a request spelled `get`. `Evaluate` repeats the
check, so an environment a caller assembled itself still cannot authorize
such a request.

Rename `validateEnv` to `validateMethod`, because the request method is
the only thing it inspects.

Record why `CompileWhere` builds an aggregate rather than wrapping. The
aggregate classifies the result as `BadParameter` for the caller and keeps
`typical`'s typed error for `errors.As`, and `Parse` does not return a
`BadParameter` for every failure.

Drop the claim that `contains` matches the role where-clause function of
the same name. `predicate.Contains` type-asserts its first argument to
`[]string` and returns false for a string, while `typical` coerces a
string to a one-element list, so the same clause text disagrees across
the two languages.

State that a trait key the identity does not have reads as an empty list,
since a mistyped key under a negation then matches every caller. State
that the `Evaluate` result is only meaningful when the error is nil, and
that sharing an `Env` across evaluations requires the caller not to
mutate its slices or map.

Cover the clauses that type-check to something other than a bool,
operator precedence, and an identity with no traits. Widen the
`strconv.Quote` round-trip generator to arbitrary strings, since the
restricted alphabet never exercised the escaping the property is about.
Rename the tests that did not name the behaviour they check, and check
that `CompileWhere` never returns a `Where` with a nil expression.

* appresource: Address review feedback on tests

Draw a well-formed clause for half the cases in
`TestCompileReturnsValueOrError`. Arbitrary strings essentially never
parse, so the success branch never ran and the test asserted nothing.
Measured 0 successful compiles in 20000 draws of `rapid.String()`, against
2496 in 5000 draws of the mixed generator.

Assert that a compile failure returns no `Where`, which no other test
checks, since `TestInvalidWhereClause` discards the value. Keep the
generator choice in a variable so a well-formed clause that fails to
compile is a test failure rather than an early return.

Drop `TestEvaluateDeterministic`. Evaluation holds no state and no map
iteration reaches the result, so the property cannot fail. The cached
parser path it exercised is already reached by the other property tests,
which compile the same clause more than once.

Use `identifierText` directly rather than aliasing it per call, now that
the generator is built once for the package. Say why the two assertions
after the `TestEnvBindings` table sit outside it, since they cover a
non-GET method and a nil traits map that the fixed request and identity
cannot express. Check the error class in `TestUnsupportedMethodRejected`
as `TestNewEnv` already does.
2026-08-04 23:14:08 +00:00

177 lines
5.9 KiB
Go

/*
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package appresource
import (
"net/http"
"slices"
"strings"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/utils/typical"
)
// validMethods are the HTTP methods a where clause evaluation accepts.
var validMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodOptions,
http.MethodTrace,
}
// whereParser is the shared cached parser for where clauses.
var whereParser = mustNewWhereParser()
// Request encodes the elements of the HTTP request a where clause is
// evaluated against.
type Request struct {
Method string
}
// Identity is the caller a where clause is evaluated against.
type Identity struct {
Name string
Roles []string
Traits map[string][]string
}
// Env holds the values one where clause evaluation reads. Request and
// Identity are deliberately not [http.Request] and tlsca.Identity, to make
// clear which fields matter for evaluation.
type Env struct {
Request Request
Identity Identity
}
// Where is a compiled where clause. Only CompileWhere returns a usable
// value. Evaluation writes nothing back to a Where, so a single Where can
// serve concurrent requests. The caller must not mutate the slices or
// map in Env during an evaluation.
type Where struct {
expression typical.Expression[Env, bool]
}
// NewEnv builds the environment a where clause is evaluated against and
// rejects a request no rule may authorize. Callers build it once per
// request, before matching any rule, because a rule without a where
// clause never reaches Evaluate.
func NewEnv(request Request, identity Identity) (Env, error) {
if err := validateMethod(request.Method); err != nil {
return Env{}, trace.Wrap(err)
}
return Env{Request: request, Identity: identity}, nil
}
// CompileWhere parses and type-checks a where clause.
func CompileWhere(expr string) (*Where, error) {
expression, err := whereParser.Parse(expr)
if err != nil {
// The aggregate classifies the result as BadParameter for the
// caller and keeps typical's typed error for errors.As. Parse does
// not return a BadParameter for every failure.
return nil, trace.NewAggregate(trace.BadParameter("compiling where clause %q", expr), err)
}
return &Where{expression: expression}, nil
}
// Evaluate reports whether the where clause matches the environment. The
// result is only meaningful when the error is nil.
func (w *Where) Evaluate(env Env) (bool, error) {
if err := validateMethod(env.Request.Method); err != nil {
return false, trace.Wrap(err)
}
match, err := w.expression.Evaluate(env)
if err != nil {
return false, trace.Wrap(err)
}
return match, nil
}
// validateMethod rejects a request method outside the canonical HTTP
// method list. NewEnv runs it at the request boundary and Evaluate
// repeats it, so an environment a caller assembled itself still cannot
// authorize such a request.
func validateMethod(method string) error {
if !slices.Contains(validMethods, method) {
return trace.BadParameter("unsupported HTTP method %q", method)
}
return nil
}
// mustNewWhereParser builds the where clause parser and panics if the
// parser spec is invalid or the expression cache cannot be built.
func mustNewWhereParser() *typical.CachedParser[Env, bool] {
p, err := typical.NewCachedParser[Env, bool](typical.ParserSpec[Env]{
Variables: map[string]typical.Variable{
// true and false are bound because typical has no bool literal.
"true": true,
"false": false,
"user.name": typical.DynamicVariable(func(e Env) (string, error) {
return e.Identity.Name, nil
}),
"user.roles": typical.DynamicVariable(func(e Env) ([]string, error) {
return e.Identity.Roles, nil
}),
// A key the identity does not have reads as an empty list, as in
// the role where-clause language, so a mistyped key under a
// negation matches every caller.
"user.traits": typical.DynamicMapFunction(func(e Env, key string) ([]string, error) {
return e.Identity.Traits[key], nil
}),
"request.method": typical.DynamicVariable(func(e Env) (string, error) {
return e.Request.Method, nil
}),
},
Functions: map[string]typical.Function{
// set and contains are named after the functions in the role
// where-clause language, services.NewWhereParser.
"set": typical.UnaryVariadicFunction[Env](func(args ...string) ([]string, error) {
return args, nil
}),
"contains": typical.BinaryFunction[Env](func(list []string, item string) (bool, error) {
return slices.Contains(list, item), nil
}),
"lower": typical.UnaryFunction[Env](func(s string) (string, error) {
return strings.ToLower(s), nil
}),
"upper": typical.UnaryFunction[Env](func(s string) (string, error) {
return strings.ToUpper(s), nil
}),
"has_prefix": typical.BinaryFunction[Env](func(s, prefix string) (bool, error) {
return strings.HasPrefix(s, prefix), nil
}),
"has_suffix": typical.BinaryFunction[Env](func(s, suffix string) (bool, error) {
return strings.HasSuffix(s, suffix), nil
}),
"has_substring": typical.BinaryFunction[Env](func(s, substr string) (bool, error) {
return strings.Contains(s, substr), nil
}),
},
})
if err != nil {
panic(trace.Wrap(err, "building the where clause parser (this is a bug)"))
}
return p
}