Files
teleport/lib/srv/app/appresources.go
T
Julia Ogris 677b0d6e1c types: Declare all app_resources fields (#69500)
* types: Declare all app_resources fields in the proto

Declare the eight reserved AppResource fields (paths, methods, where,
allow_encoded, allow_code, allow_reason, deny_code_hint,
deny_reason_hint) and the AppResourcesExpressions role condition, and
regenerate the proto stubs, the role CRDs, the Terraform schema and
docs, and the derived equality functions. Declaring the fields before
the fine-grained matcher merges lets every client of this version name
them, so a client that round-trips a role written by a newer auth
preserves the fields instead of silently dropping them on write-back,
which would widen the rule.

Tighten IsAllowAllOnly to require every declared field besides
allow_all to be unset. Without this, a rule such as {allow_all: true,
methods: [GET]} written by a newer auth would pass the check once the
fields are named, and this version would treat a method-restricted
rule as unrestricted.

Add GetAppResourcesExpressions to the Role interface, and read it in
decideMinimalV9 so the app agent denies a request governed by a
predicate it cannot evaluate. A predicate restricts the rules it
accompanies, so a role that pairs allow_all with one is not
unrestricted, and a deny-side predicate blocks another role's
allow_all the same way a deny-side rule does. Auth already treats both
as restricting when it downgrades a role for a pre-v9 client, so
without this the agent and auth disagree about the same role.

Strip AppResourcesExpressions in the v9-to-v8 downgrade alongside
AppResources, and reject it at write, so a role can neither reach a
pre-v9 client with the restriction silently removed nor be stored
while this version cannot enforce it.

* docs: List the declared app_resources fields

Honour the docs review on the app-resources reference page. A run of
inline code-style field names inside a sentence is hard to scan, so the
eight rejected `app_resources` fields become a list, and
`app_resources_expressions` gets its own sentence rather than trailing
the same run.

* buf: Trim the reserved-deletion except comment

Drop the two sentences explaining why buf's ignore lists cannot scope the
except. The rule name and the removal condition are what a reader needs
here, and the scoping attempts are recorded on the pull request.

* types: Reword the app_resources_expressions and Methods docs

Honour the review on the proto field comments. Drop "desugared" from
`AppResourcesExpressions`, which needs the RFD to gloss, and state the
relation between the two forms directly instead. Open `Methods` with the
plural, matching `Paths` on the row above.

Regenerate the proto stubs, the role CRDs, the Terraform schema and docs.
2026-08-12 05:19:01 +00:00

197 lines
7.8 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 app
import (
"net/http"
"slices"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/wrappers"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/services"
)
// denyKindRequestNotAllowed is the deny kind reported to the client when a v9
// role denies an HTTP app request by default.
const denyKindRequestNotAllowed = "teleport_request_not_allowed"
// minimalV9Decision is the outcome of the minimal v9 default-deny check
// for one HTTP app request. Only allow_all is honored.
//
// TODO(@juliaogris): Replace with per-request rule matching from the
// upcoming lib/appresource engine package.
type minimalV9Decision struct {
// enforced is true when a v9 role grants the app, so v9 default-deny
// governs the request instead of v8 pass-through.
enforced bool
// allowed is true when a granting v9 role sets allow_all, which
// forwards the request untouched, exactly as v8 did.
allowed bool
// droppedRoles names the pre-v9 roles dropped because a v9 role grants
// the same app. They are logged, never allowed to re-open access.
droppedRoles []string
// versionSkew is true when the roles set app rules, predicates, or
// versions a newer Teleport wrote and this version cannot evaluate.
// The cases are deny-side rules or predicates, allow rules beyond a
// single pure allow_all, allow-side predicates, and a role version
// above v9.
versionSkew bool
}
// roleVersionPredatesV9 reports whether the role version predates v9
// default-deny.
func roleVersionPredatesV9(version string) bool {
switch version {
case types.V1, types.V2, types.V3, types.V4, types.V5, types.V6, types.V7, types.V8:
return true
}
return false
}
// decideMinimalV9 applies the minimal v9 policy to the caller's roles that
// grant app. If only pre-v9 roles grant it, the request keeps full v8
// behavior. Otherwise pre-v9 roles granting the app are dropped and the
// request is denied unless a granting v9 role holds a single allow_all rule,
// sets no app_resources_expressions, and no role sets a deny-side rule or
// predicate. A role newer than v9 still enforces but never allows, since it
// may set restrictions this version cannot evaluate.
//
// TODO(@juliaogris): Replace with per-request rule matching from the
// upcoming lib/appresource engine package.
func decideMinimalV9(roles []types.Role, app types.Application, username string, traits wrappers.Traits) minimalV9Decision {
// This version cannot evaluate deny-side app rules or predicates, which
// could only occur in roles from newer versions. Deny beats allow across
// the whole role set, so any role that sets either blocks allow_all and
// the request is denied.
denyAppRules := slices.ContainsFunc(roles, func(role types.Role) bool {
return len(role.GetAppResources(types.Deny)) > 0 ||
len(role.GetAppResourcesExpressions(types.Deny)) > 0
})
decision := minimalV9Decision{versionSkew: denyAppRules}
for _, role := range roles {
if !services.RoleGrantsResource(role, app, username, traits) {
continue
}
if roleVersionPredatesV9(role.GetVersion()) {
decision.droppedRoles = append(decision.droppedRoles, role.GetName())
continue
}
decision.enforced = true
if role.GetVersion() != types.V9 {
// A newer version may add restrictions this agent cannot
// evaluate, so a role above v9 denies rather than allows, even
// when its known fields look like a plain allow_all.
decision.versionSkew = true
continue
}
allow := role.GetAppResources(types.Allow)
allowExpressions := role.GetAppResourcesExpressions(types.Allow)
switch {
case len(allowExpressions) > 0:
// A predicate restricts the rules it accompanies, and this
// version cannot evaluate one, so the role denies whether or
// not its declarative rules read as allow_all.
decision.versionSkew = true
case types.AppResourcesAllowAll(allow, role.GetAppResources(types.Deny)):
decision.allowed = !denyAppRules
case len(allow) > 0:
// This version can only write a single pure allow_all rule, so
// any other non-empty rule set must come from a newer version.
decision.versionSkew = true
}
}
if !decision.enforced {
decision.droppedRoles = nil
decision.versionSkew = false
}
return decision
}
// enforceMinimalV9 applies v9 default-deny to a plain HTTP app request. It
// returns true when the request is denied, after writing a 403 response
// naming the deny kind. Cloud apps (AWS console, Azure, GCP) and LLM apps
// never reach this path. Earlier cases in serveHTTP handle them.
//
// TODO(@juliaogris): Replace with per-request rule matching from the
// upcoming lib/appresource engine package.
func (c *ConnectionsHandler) enforceMinimalV9(w http.ResponseWriter, r *http.Request, authCtx *authz.Context, app types.Application) (bool, error) {
identity := authCtx.Identity.GetIdentity()
if !isGovernedByAppResources(app) {
// Skip rather than deny, because v9 does not restrict these app types
// and denying would break a working app on a routing bug.
if c.v9WarnOnce("apptype", identity.Username, app.GetName()) {
c.log.WarnContext(r.Context(), "Skipped app_resources enforcement because v9 does not govern this app type.",
"app", app.GetName(),
"user", identity.Username,
"app_sub_kind", app.GetSubKind(),
)
}
return false, nil
}
decision := decideMinimalV9(authCtx.Checker.Roles(), app, identity.Username, authCtx.Checker.Traits())
if len(decision.droppedRoles) > 0 && c.v9WarnOnce("drop", identity.Username, app.GetName()) {
c.log.WarnContext(r.Context(), "Dropped v8-or-older roles that grant a v9-governed app; v8 roles cannot re-open unrestricted access.",
"app", app.GetName(),
"user", identity.Username,
"dropped_roles", decision.droppedRoles,
)
}
if !decision.enforced || decision.allowed {
return false, nil
}
if decision.versionSkew && c.v9WarnOnce("skew", identity.Username, app.GetName()) {
c.log.WarnContext(r.Context(), "Denied app request: the user's roles carry app_resources rules or role versions that this Teleport version does not implement, and unimplemented rules deny by default. Upgrade this app agent to enforce the intended rules.",
"app", app.GetName(),
"user", identity.Username,
)
}
if isCORSPreflight(r) && c.v9WarnOnce("cors", identity.Username, app.GetName()) {
c.log.WarnContext(r.Context(), "Denied CORS preflight: the app denies requests by default and no v9 rule allows OPTIONS.",
"app", app.GetName(),
"user", identity.Username,
)
}
http.Error(w, denyKindRequestNotAllowed, http.StatusForbidden)
return true, nil
}
// isGovernedByAppResources reports whether v9 app_resources rules govern this
// app type.
func isGovernedByAppResources(app types.Application) bool {
return !app.IsAWSConsole() && !app.IsAzureCloud() && !app.IsGCP() && !app.IsLLM() &&
app.GetSubKind() != types.KindIdentityCenterAccount
}
// isCORSPreflight reports whether r is a CORS preflight request, an
// OPTIONS request carrying both the Origin and
// Access-Control-Request-Method headers.
func isCORSPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions &&
r.Header.Get("Origin") != "" &&
r.Header.Get("Access-Control-Request-Method") != ""
}