mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-25 10:40:02 +08:00
* 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.
177 lines
5.9 KiB
Go
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
|
|
}
|