mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
* 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.
197 lines
7.8 KiB
Go
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") != ""
|
|
}
|