mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
1417 lines
48 KiB
Go
1417 lines
48 KiB
Go
// Teleport
|
|
// Copyright (C) 2025 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 joining
|
|
|
|
import (
|
|
"cmp"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
|
|
joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/lib/join/provision"
|
|
"github.com/gravitational/teleport/lib/scopes"
|
|
)
|
|
|
|
var rolesSupportingScopes = types.SystemRoles{
|
|
types.RoleNode,
|
|
types.RoleKube,
|
|
types.RoleApp,
|
|
types.RoleBot,
|
|
}
|
|
|
|
// TokenUsageMode represents the possible usage modes of a scoped token.
|
|
type TokenUsageMode string
|
|
|
|
const (
|
|
// TokenUsageModeSingle denotes a token that can only provision a single resource.
|
|
TokenUsageModeSingle TokenUsageMode = "single_use"
|
|
// TokenUsageModeUnlimited denotes a token that can provision any number of resources.
|
|
TokenUsageModeUnlimited = "unlimited"
|
|
// TokenUsageModeBot denotes a token that can provision bots. Bot joining
|
|
// perform additional verification steps that can affect token reusability.
|
|
TokenUsageModeBot = "bot"
|
|
)
|
|
|
|
// validates the given Kubernetes configuration. Also implemented by
|
|
// lib/services/provisioning.go:strongValidateProvisionTokenWithDefaults() for unscoped tokens
|
|
func validateKubernetes(kube *joiningv1.Kubernetes) error {
|
|
if kube == nil || len(kube.GetAllow()) == 0 {
|
|
return trace.BadParameter("at least one allow rule must be set")
|
|
}
|
|
|
|
for i, rule := range kube.GetAllow() {
|
|
serviceAccountSet := rule.GetServiceAccount() != ""
|
|
serviceAccountNameSet := rule.GetServiceAccountName() != ""
|
|
serviceAccountNamespaceSet := rule.GetServiceAccountNamespace() != ""
|
|
|
|
if !serviceAccountSet && (!serviceAccountNameSet || !serviceAccountNamespaceSet) {
|
|
return trace.BadParameter(
|
|
"allow[%d]: must specify service_account or (service_account_name and service_account_namespace)",
|
|
i,
|
|
)
|
|
}
|
|
|
|
if serviceAccountSet {
|
|
parts := strings.Split(rule.GetServiceAccount(), ":")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return trace.BadParameter(
|
|
"allow[%d].service_account should be in format \"namespace:service_account\", got %q instead",
|
|
i,
|
|
rule.GetServiceAccount(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
switch types.KubernetesJoinType(kube.GetType()) {
|
|
case types.KubernetesJoinTypeInCluster:
|
|
if kube.GetStaticJwks() != nil {
|
|
return trace.BadParameter("static_jwks must not be set when type is %q", kube.GetType())
|
|
}
|
|
|
|
if kube.GetOidc() != nil {
|
|
return trace.BadParameter("oidc must not be set when type is %q", kube.GetType())
|
|
}
|
|
case types.KubernetesJoinTypeStaticJWKS:
|
|
if kube.GetStaticJwks().GetJwks() == "" {
|
|
return trace.BadParameter("static_jwks must be set when type is %q", kube.GetType())
|
|
}
|
|
if kube.GetOidc() != nil {
|
|
return trace.BadParameter("oidc must not be set when type is %q", kube.GetType())
|
|
}
|
|
case types.KubernetesJoinTypeOIDC:
|
|
if kube.GetOidc().GetIssuer() == "" {
|
|
return trace.BadParameter("oidc.issuer must be set when type is %q", kube.GetType())
|
|
}
|
|
if kube.GetStaticJwks() != nil {
|
|
return trace.BadParameter("static_jwks must not be set when type is %q", kube.GetType())
|
|
}
|
|
|
|
parsed, err := url.Parse(kube.GetOidc().GetIssuer())
|
|
if err != nil {
|
|
return trace.BadParameter("oidc.issuer must be a valid URL")
|
|
}
|
|
|
|
if parsed.Scheme == "http" {
|
|
if !kube.GetOidc().GetInsecureAllowHttpIssuer() {
|
|
return trace.BadParameter("oidc.issuer must be https:// unless insecure_allow_http_issuer is set")
|
|
}
|
|
} else if parsed.Scheme != "https" {
|
|
return trace.BadParameter("invalid oidc.issuer URL scheme, must be https://")
|
|
}
|
|
case types.KubernetesJoinTypeUnspecified:
|
|
return trace.BadParameter("type must be specified")
|
|
default:
|
|
return trace.BadParameter(
|
|
"unrecognized type %q, must be one of (%s)",
|
|
kube.GetType(),
|
|
strings.Join([]string{
|
|
string(types.KubernetesJoinTypeInCluster),
|
|
string(types.KubernetesJoinTypeStaticJWKS),
|
|
string(types.KubernetesJoinTypeOIDC),
|
|
}, ", "),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given AWS EC2 configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenV2.CheckAndSetDefaults() for unscoped tokens
|
|
func validateEC2(aws *joiningv1.AWS) error {
|
|
ttl := aws.GetIidTtl()
|
|
if _, err := types.ParseDuration(ttl); ttl != "" && err != nil {
|
|
return trace.BadParameter("invalid IID TTL value %q, must be empty or a valid duration string (e.g. 30m, 12h, 1mo)", ttl)
|
|
}
|
|
|
|
if len(aws.GetAllow()) == 0 {
|
|
return trace.BadParameter("aws configuration with at least one allow rule must be defined")
|
|
}
|
|
|
|
for i, rule := range aws.GetAllow() {
|
|
if rule.GetAwsArn() != "" {
|
|
return trace.BadParameter(`allow[%d]: "aws_arn" parameter not supported`, i)
|
|
}
|
|
if rule.GetAwsOrganizationId() != "" {
|
|
return trace.BadParameter(`allow[%d]: "aws_organization_id" parameter not supported`, i)
|
|
}
|
|
if rule.GetAwsAccount() == "" && rule.GetAwsRole() == "" {
|
|
return trace.BadParameter(`allow[%d]: must set "aws_account" or "aws_role"`, i)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given AWS IAM configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenV2.CheckAndSetDefaults() for unscoped tokens
|
|
func validateIAM(aws *joiningv1.AWS) error {
|
|
if len(aws.GetAllow()) == 0 {
|
|
return trace.BadParameter("aws configuration with at least one allow rule must be defined")
|
|
}
|
|
|
|
for i, rule := range aws.GetAllow() {
|
|
if rule.GetAwsRole() != "" {
|
|
return trace.BadParameter(`allow[%d]: "aws_role" parameter not supported`, i)
|
|
}
|
|
if len(rule.GetAwsRegions()) != 0 {
|
|
return trace.BadParameter(`allow[%d]: "aws_regions" parameter not supported`, i)
|
|
}
|
|
if rule.GetAwsAccount() == "" && rule.GetAwsArn() == "" && rule.GetAwsOrganizationId() == "" {
|
|
return trace.BadParameter(`allow[%d]: must set "aws_account", "aws_arn", or "aws_organization"`, i)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given GCP configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenSpecV2GCP.checkAndSetDefaults() for unscoped tokens
|
|
func validateGCP(gcp *joiningv1.GCP) error {
|
|
if len(gcp.GetAllow()) == 0 {
|
|
return trace.BadParameter("gcp configuration with at least one allow rule must be defined")
|
|
}
|
|
|
|
for i, rule := range gcp.GetAllow() {
|
|
if len(rule.GetProjectIds()) == 0 {
|
|
return trace.BadParameter("allow[%d]: requires at least one project ID", i)
|
|
}
|
|
for j, projectID := range rule.GetProjectIds() {
|
|
if len(projectID) == 0 {
|
|
return trace.BadParameter("allow[%d].project_ids[%d]: must not be empty", i, j)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given Azure configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenSpecV2Azure.checkAndSetDefaults() for unscoped tokens
|
|
func validateAzure(azure *joiningv1.Azure) error {
|
|
if len(azure.GetAllow()) == 0 {
|
|
return trace.BadParameter("azure configuration with at least one allow rule must be defined")
|
|
}
|
|
|
|
for i, rule := range azure.GetAllow() {
|
|
if rule.GetSubscription() == "" && rule.GetTenant() == "" {
|
|
return trace.BadParameter(`allow[%d]: must set "subscription" or "tenant"`, i)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given Azure DevOps configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenSpecV2AzureDevops.checkAndSetDefaults() for unscoped tokens
|
|
func validateAzureDevops(azdo *joiningv1.AzureDevops) error {
|
|
if len(azdo.GetAllow()) == 0 {
|
|
return trace.BadParameter("azure_devops configuration with at least one allow rule must be defined")
|
|
}
|
|
if azdo.GetOrganizationId() == "" {
|
|
return trace.BadParameter(`"organization_id" must be set`)
|
|
}
|
|
|
|
for i, rule := range azdo.GetAllow() {
|
|
if rule.GetSub() == "" && rule.GetProjectName() == "" && rule.GetProjectId() == "" {
|
|
return trace.BadParameter(
|
|
`allow[%d]: must set "sub", "project_name", or "project_id"`,
|
|
i,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given Oracle configuration. Also implemented by
|
|
// api/types/provisioning.go:ProvisionTokenSpecV2Oracle.checkAndSetDefaults() for unscoped tokens
|
|
func validateOracle(oracle *joiningv1.Oracle) error {
|
|
if len(oracle.GetAllow()) == 0 {
|
|
return trace.BadParameter("oracle configuration with at least one allow rule must be defined")
|
|
}
|
|
|
|
for i, rule := range oracle.GetAllow() {
|
|
if rule.GetTenancy() == "" {
|
|
return trace.BadParameter(`allow[%d]: "tenancy" must be set`, i)
|
|
}
|
|
if len(rule.GetInstances()) > 100 {
|
|
return trace.BadParameter("allow[%d]: maximum 100 instances may be set (found %d)", i, len(rule.GetInstances()))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validates the given Generic OIDC configuration. Also implemented by
|
|
// api/types/provisioning.go for unscoped generic_oidc tokens.
|
|
func validateGenericOIDC(spec *joiningv1.GenericOIDC) error {
|
|
if spec == nil {
|
|
return trace.BadParameter("generic_oidc configuration must be defined for a scoped token when using the generic_oidc join method")
|
|
}
|
|
if spec.GetIssuer() == "" {
|
|
return trace.BadParameter("generic_oidc: issuer is required")
|
|
}
|
|
if spec.GetAudience() == "" {
|
|
return trace.BadParameter("generic_oidc: audience is required")
|
|
}
|
|
|
|
hasAnyAllowAny := len(spec.GetAllowAny()) > 0
|
|
hasAnyMustMatchFields := false
|
|
if spec.GetMustMatchFields() != nil {
|
|
hasAnyMustMatchFields = len(spec.GetMustMatchFields().GetFields()) > 0
|
|
}
|
|
|
|
// At least one must_match_fields or allow_any rule is required; this check
|
|
// is a simpler variant of the one in genericoidc's
|
|
// `validateFieldRulesContainsAnyRule` and won't catch useless nesting
|
|
// checks; we'll catch those at runtime to avoid an unnecessary api/ import.
|
|
if !hasAnyAllowAny && !hasAnyMustMatchFields {
|
|
return trace.BadParameter("generic_oidc: at least one rule must exist " +
|
|
"under either `must_match_fields` or `allow_any`")
|
|
}
|
|
|
|
for i, rule := range spec.GetAllowAny() {
|
|
if rule.GetExpression() == "" && len(rule.GetConditions()) == 0 {
|
|
return trace.BadParameter("generic_oidc: allow_any[%d]: either `expression` or `conditions` must be set", i)
|
|
}
|
|
|
|
if rule.GetExpression() != "" && len(rule.GetConditions()) > 0 {
|
|
return trace.BadParameter("generic_oidc: allow_any[%d]: only one of `expression` or `conditions` may be set", i)
|
|
}
|
|
|
|
for j, cond := range rule.GetConditions() {
|
|
if cond.GetAttribute() == "" {
|
|
return trace.BadParameter(
|
|
"generic_oidc: allow_any[%d].conditions[%d]: an attribute "+
|
|
"is required", i, j)
|
|
}
|
|
|
|
conds := 0
|
|
if cond.GetEq() != nil {
|
|
conds++
|
|
}
|
|
if cond.GetNotEq() != nil {
|
|
conds++
|
|
}
|
|
if cond.GetIn() != nil {
|
|
conds++
|
|
}
|
|
if cond.GetNotIn() != nil {
|
|
conds++
|
|
}
|
|
|
|
if conds == 0 || conds > 1 {
|
|
return trace.BadParameter(
|
|
"generic_oidc: allow_any[%d].conditions[%d]: exactly one "+
|
|
"operator is required", i, j)
|
|
}
|
|
}
|
|
}
|
|
|
|
parsed, err := url.Parse(spec.GetIssuer())
|
|
if err != nil {
|
|
return trace.BadParameter("generic_oidc: issuer must be a valid URL")
|
|
}
|
|
|
|
if parsed.Scheme == "http" {
|
|
if !spec.GetInsecureAllowHttpIssuer() {
|
|
return trace.BadParameter("generic_oidc: issuer must be https:// unless insecure_allow_http_issuer is set")
|
|
}
|
|
} else if parsed.Scheme != "https" {
|
|
return trace.BadParameter("generic_oidc: issuer invalid URL scheme, must be https://")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateGithub validates the GitHub-specific scoped token configuration.
|
|
// It checks that the token usage mode is compatible, that enterprise_server_host
|
|
// does not contain a scheme or path, that enterprise_server_host and enterprise_slug
|
|
// are mutually exclusive, and that at least one allow rule with a non-empty
|
|
// field is set.
|
|
func validateGithub(spec *joiningv1.Github, tokenUsageMode TokenUsageMode) error {
|
|
if spec == nil {
|
|
return trace.BadParameter("github configuration must be defined for a scoped token when using the github join method")
|
|
}
|
|
if tokenUsageMode == TokenUsageModeSingle {
|
|
return trace.BadParameter("usage mode %q is not supported for github join method", TokenUsageModeSingle)
|
|
}
|
|
if strings.Contains(spec.GetEnterpriseServerHost(), "/") {
|
|
return trace.BadParameter("'github.enterprise_server_host' should not contain the scheme or path")
|
|
}
|
|
if spec.GetEnterpriseServerHost() != "" && spec.GetEnterpriseSlug() != "" {
|
|
return trace.BadParameter("'github.enterprise_server_host' and 'github.enterprise_slug' cannot both be set")
|
|
}
|
|
|
|
if len(spec.GetAllow()) == 0 {
|
|
return trace.BadParameter("the github join method requires at least one token allow rule")
|
|
}
|
|
|
|
for _, rule := range spec.GetAllow() {
|
|
repoSet := rule.GetRepository() != ""
|
|
ownerSet := rule.GetRepositoryOwner() != ""
|
|
subSet := rule.GetSub() != ""
|
|
enterpriseSet := rule.GetEnterprise() != ""
|
|
enterpriseIDSet := rule.GetEnterpriseId() != ""
|
|
if !subSet && !ownerSet && !repoSet && !enterpriseSet && !enterpriseIDSet {
|
|
return trace.BadParameter(`allow rule for github must include at least one of "repository", "repository_owner", "sub", "enterprise" or "enterprise_id"`)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateGitLab validates the GitLab-specific scoped token configuration.
|
|
// The token usage mode must not be "single".
|
|
// The domain must not contain a scheme or path.
|
|
// There must be at least one allow rule.
|
|
// Each allow rule must have at least one of ['sub', 'project_path', 'namespace_path', 'ci_config_ref_uri'].
|
|
func validateGitLab(spec *joiningv1.GitLab, tokenUsageMode TokenUsageMode) error {
|
|
if spec == nil {
|
|
return trace.BadParameter("gitlab configuration must be defined for a scoped token when using the gitlab join method")
|
|
}
|
|
if tokenUsageMode == TokenUsageModeSingle {
|
|
return trace.BadParameter("usage mode %q is not supported for gitlab join method", TokenUsageModeSingle)
|
|
}
|
|
|
|
// empty domain field allowed
|
|
if spec.GetDomain() != "" && strings.Contains(spec.GetDomain(), "/") {
|
|
return trace.BadParameter("'spec.gitlab.domain' should not contain a scheme or path")
|
|
}
|
|
|
|
if len(spec.GetAllow()) == 0 {
|
|
return trace.BadParameter("the gitlab join method requires defined gitlab allow rules")
|
|
}
|
|
|
|
for _, allowRule := range spec.GetAllow() {
|
|
if allowRule.GetSub() == "" && allowRule.GetNamespacePath() == "" && allowRule.GetProjectPath() == "" && allowRule.GetCiConfigRefUri() == "" {
|
|
return trace.BadParameter("the gitlab join method requires allow rules with at least one of ['sub', 'project_path', 'namespace_path', 'ci_config_ref_uri'] to ensure security.")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateTPM validates the TPM-specific scoped token configuration. Note that
|
|
// checks from ProvisionTokenSpecV2TPM.validate() are replicated here.
|
|
func validateTPM(spec *joiningv1.TPM) error {
|
|
if spec == nil {
|
|
return trace.BadParameter("tpm: the .spec.tpm field is required for this join method")
|
|
}
|
|
|
|
for i, caData := range spec.GetEkcertAllowedCas() {
|
|
p, _ := pem.Decode([]byte(caData))
|
|
if p == nil {
|
|
return trace.BadParameter(
|
|
"ekcert_allowed_cas[%d]: no pem block found",
|
|
i,
|
|
)
|
|
}
|
|
if p.Type != "CERTIFICATE" {
|
|
return trace.BadParameter(
|
|
"ekcert_allowed_cas[%d]: pem block is not 'CERTIFICATE' type",
|
|
i,
|
|
)
|
|
}
|
|
if _, err := x509.ParseCertificate(p.Bytes); err != nil {
|
|
return trace.Wrap(
|
|
err,
|
|
"ekcert_allowed_cas[%d]: parsing certificate",
|
|
i,
|
|
)
|
|
}
|
|
}
|
|
|
|
if len(spec.GetAllow()) == 0 {
|
|
return trace.BadParameter(
|
|
"allow: at least one rule must be set",
|
|
)
|
|
}
|
|
|
|
hasCAs := len(spec.GetEkcertAllowedCas()) > 0
|
|
for i, allowRule := range spec.GetAllow() {
|
|
if len(allowRule.GetEkPublicHash()) == 0 && len(allowRule.GetEkCertificateSerial()) == 0 {
|
|
return trace.BadParameter(
|
|
"allow[%d]: at least one of ['ek_public_hash', 'ek_certificate_serial'] must be set",
|
|
i,
|
|
)
|
|
}
|
|
|
|
// This is ported from services/local/provisioning.go's
|
|
// validateTPMToken() which was deliberately separate from the overall
|
|
// CheckAndSetDefaults() -> validate() path so as to not affect existing
|
|
// tokens. There are no existing scoped TPM tokens, so we can safely
|
|
// inline it here.
|
|
//
|
|
// This check doesn't apply if CAs are present: per the source impl,
|
|
// serials are not trustworthy when certificates are verified against a
|
|
// configured CA, so they're optional if CAs are also set.
|
|
hasSerialWithoutHash := allowRule.GetEkCertificateSerial() != "" && allowRule.GetEkPublicHash() == ""
|
|
if !hasCAs && hasSerialWithoutHash {
|
|
return trace.BadParameter(
|
|
"allow[%d]: ek_certificate_serial requires ek_public_hash or "+
|
|
"ekcert_allowed_cas to be set so that the EK certificate "+
|
|
"can be verified", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validates per join method token configurations
|
|
func validateJoinMethod(token *joiningv1.ScopedToken) error {
|
|
switch types.JoinMethod(token.GetSpec().GetJoinMethod()) {
|
|
case types.JoinMethodToken:
|
|
if token.GetStatus().GetSecret() == "" {
|
|
return trace.BadParameter("secret value must be defined for a scoped token when using the token join method")
|
|
}
|
|
case types.JoinMethodEC2:
|
|
return trace.Wrap(validateEC2(token.GetSpec().GetAws()), "ec2 join method")
|
|
case types.JoinMethodIAM:
|
|
return trace.Wrap(validateIAM(token.GetSpec().GetAws()), "iam join method")
|
|
case types.JoinMethodGCP:
|
|
return trace.Wrap(validateGCP(token.GetSpec().GetGcp()), "gcp join method")
|
|
case types.JoinMethodAzure:
|
|
return trace.Wrap(validateAzure(token.GetSpec().GetAzure()), "azure join method")
|
|
case types.JoinMethodAzureDevops:
|
|
return trace.Wrap(validateAzureDevops(token.GetSpec().GetAzureDevops()), "azure_devops join method")
|
|
case types.JoinMethodOracle:
|
|
return trace.Wrap(validateOracle(token.GetSpec().GetOracle()), "oracle join method")
|
|
case types.JoinMethodKubernetes:
|
|
return trace.Wrap(validateKubernetes(token.GetSpec().GetKubernetes()), "kubernetes join method")
|
|
case types.JoinMethodBoundKeypair:
|
|
// Bound keypair tokens are always valid
|
|
case types.JoinMethodGenericOIDC:
|
|
return trace.Wrap(validateGenericOIDC(token.GetSpec().GetGenericOidc()), "generic_oidc join method")
|
|
case types.JoinMethodGitHub:
|
|
if err := validateGithub(token.GetSpec().GetGithub(), TokenUsageMode(token.GetSpec().GetUsageMode())); err != nil {
|
|
return trace.Wrap(err, "github join method")
|
|
}
|
|
case types.JoinMethodGitLab:
|
|
return trace.Wrap(validateGitLab(token.GetSpec().GetGitlab(), TokenUsageMode(token.GetSpec().GetUsageMode())), "gitlab join method")
|
|
case types.JoinMethodTPM:
|
|
return trace.Wrap(validateTPM(token.GetSpec().GetTpm()), "tpm join method")
|
|
default:
|
|
return trace.BadParameter("join method %q does not support scoping", token.GetSpec().GetJoinMethod())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateBot is used to validate plausibly-bot tokens (if `isBotToken()` has
|
|
// returned true).
|
|
func strongValidateBotToken(token *joiningv1.ScopedToken, roles types.SystemRoles) error {
|
|
spec := token.GetSpec()
|
|
|
|
if spec.GetBot() == "" {
|
|
return trace.BadParameter("expected non-empty bot for a scoped bot token")
|
|
}
|
|
|
|
bot, err := scopes.ParseQualifiedName(spec.GetBot())
|
|
if err != nil {
|
|
return trace.Wrap(err, "validating scoped token bot")
|
|
}
|
|
|
|
if err := bot.StrongValidate(); err != nil {
|
|
return trace.Wrap(err, "validating scoped token bot")
|
|
}
|
|
|
|
if spec.GetUsageMode() != TokenUsageModeBot {
|
|
return trace.BadParameter("usage_mode must be '%s' for a scoped bot token", TokenUsageModeBot)
|
|
}
|
|
|
|
if len(roles) != 1 || !roles.Include(types.RoleBot) {
|
|
return trace.BadParameter("roles must only be '[Bot]' for a scoped bot token")
|
|
}
|
|
|
|
if !scopes.ScopeOfOrigin(token.GetScope()).IsAssignableToScopeOfEffect(bot.Scope) {
|
|
return trace.BadParameter("scoped token bot scope must be a descendant of or equivalent to its resource scope")
|
|
}
|
|
|
|
if spec.GetAssignedScope() != "" {
|
|
return trace.BadParameter("scoped tokens for bots cannot have an assigned_scope")
|
|
}
|
|
|
|
if spec.GetJoinMethod() == string(types.JoinMethodToken) {
|
|
return trace.BadParameter("scoped bot tokens do not support the `token` join method, `bound_keypair` should be used instead")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateNonBot performs checks for scoped tokens that explicitly should not
|
|
// exist for non-bot tokens.
|
|
func validateNonBotToken(token *joiningv1.ScopedToken) error {
|
|
spec := token.GetSpec()
|
|
|
|
if spec.GetBot() != "" {
|
|
return trace.BadParameter("bot cannot be set for a non-bot token")
|
|
}
|
|
|
|
if spec.GetUsageMode() == TokenUsageModeBot {
|
|
return trace.BadParameter("usage_mode cannot be 'bot' for a non-bot token")
|
|
}
|
|
|
|
if err := scopes.StrongValidate(spec.GetAssignedScope()); err != nil {
|
|
return trace.Wrap(err, "validating scoped token assigned scope")
|
|
}
|
|
|
|
if !scopes.ScopeOfOrigin(token.GetScope()).IsAssignableToScopeOfEffect(spec.GetAssignedScope()) {
|
|
return trace.BadParameter("scoped token assigned scope must be descendant of or equivalent to the token's resource scope")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateBotRef weak-validates the bot reference of a scoped token spec and
|
|
// returns the referenced bot's name and scope, parsed from the
|
|
// scope-qualified name in the bot field. Bot tokens must carry a well-formed
|
|
// scope-qualified name. For non-bot tokens the bot field is ignored and empty
|
|
// values are returned: a stray bot field on a non-bot token is rejected by
|
|
// strong validation at write time, but tolerated here to match weak
|
|
// validation's posture toward existing resources.
|
|
func validateBotRef(spec *joiningv1.ScopedTokenSpec, isBotToken bool) (name, scope string, err error) {
|
|
if !isBotToken {
|
|
return "", "", nil
|
|
}
|
|
|
|
if spec.GetBot() == "" {
|
|
return "", "", trace.BadParameter("expected non-empty bot for a scoped bot token")
|
|
}
|
|
bot, err := scopes.ParseQualifiedName(spec.GetBot())
|
|
if err != nil {
|
|
return "", "", trace.Wrap(err, "validating scoped token bot")
|
|
}
|
|
if err := bot.WeakValidate(); err != nil {
|
|
return "", "", trace.Wrap(err, "validating scoped token bot")
|
|
}
|
|
|
|
return bot.Name, bot.Scope, nil
|
|
}
|
|
|
|
// StrongValidateToken checks if the scoped token is well-formed according to
|
|
// all scoped token rules. This function *must* be used to validate any scoped
|
|
// token being created from scratch. When validating existing scoped token
|
|
// resources, this function should be avoided in favor of the
|
|
// [WeakValidateToken] function.
|
|
func StrongValidateToken(token *joiningv1.ScopedToken) error {
|
|
if expected, actual := types.KindScopedToken, token.GetKind(); expected != actual {
|
|
return trace.BadParameter("expected kind %v, got %q", expected, actual)
|
|
}
|
|
if expected, actual := types.V1, token.GetVersion(); expected != actual {
|
|
return trace.BadParameter("expected version %v, got %q", expected, actual)
|
|
}
|
|
if expected, actual := "", token.GetSubKind(); expected != actual {
|
|
return trace.BadParameter("expected sub_kind %v, got %q", expected, actual)
|
|
}
|
|
if err := scopes.StrongValidateResourceName(token.GetMetadata().GetName()); err != nil {
|
|
return trace.Wrap(err, "validating scoped token name")
|
|
}
|
|
|
|
if token.GetScope() == "" {
|
|
return trace.BadParameter("scoped token must have a scope assigned")
|
|
}
|
|
|
|
spec := token.GetSpec()
|
|
if spec == nil {
|
|
return trace.BadParameter("spec must not be nil")
|
|
}
|
|
|
|
if err := scopes.StrongValidate(token.GetScope()); err != nil {
|
|
return trace.Wrap(err, "validating scoped token resource scope")
|
|
}
|
|
|
|
if err := validateJoinMethod(token); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
switch TokenUsageMode(spec.GetUsageMode()) {
|
|
case TokenUsageModeSingle, TokenUsageModeUnlimited, TokenUsageModeBot:
|
|
default:
|
|
return trace.BadParameter("scoped token mode is not supported")
|
|
}
|
|
|
|
if len(spec.GetRoles()) == 0 {
|
|
return trace.BadParameter("scoped token must have at least one role")
|
|
}
|
|
|
|
roles, err := types.NewTeleportRoles(spec.GetRoles())
|
|
if err != nil {
|
|
return trace.Wrap(err, "validating scoped token roles")
|
|
}
|
|
|
|
if err := validateImmutableLabels(spec); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
for _, role := range roles {
|
|
if !rolesSupportingScopes.Include(role) {
|
|
return trace.BadParameter("role %q does not support scoping", role)
|
|
}
|
|
}
|
|
|
|
if roles.Include(types.RoleBot) {
|
|
if err := strongValidateBotToken(token, roles); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
} else {
|
|
if err := validateNonBotToken(token); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WeakValidateToken performs a weak form of validation on a scoped token. This
|
|
// function is intended to catch bugs/incompatibilites that might have resulted
|
|
// in a scoped token too malformed for us to safely reason about (e.g. due to
|
|
// significant version drift). Use this function to validate scoped tokens
|
|
// propagated from the control plane. Prefer using [StrongValidateToken] when
|
|
// building a new scoped token from scratch.
|
|
func WeakValidateToken(token *joiningv1.ScopedToken) error {
|
|
if token == nil {
|
|
return trace.BadParameter("missing scoped token")
|
|
}
|
|
|
|
if err := scopes.WeakValidate(token.GetScope()); err != nil {
|
|
return trace.Wrap(err, "validating scoped token resource scope")
|
|
}
|
|
|
|
spec := token.GetSpec()
|
|
|
|
// Determine if this is a bot token without potentially trying to validate
|
|
// other unknown roles.
|
|
isBotToken := slices.Contains(spec.GetRoles(), string(types.RoleBot))
|
|
if _, _, err := validateBotRef(spec, isBotToken); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if !isBotToken {
|
|
if err := scopes.WeakValidate(token.GetSpec().GetAssignedScope()); err != nil {
|
|
return trace.Wrap(err, "validating scoped token assigned scope")
|
|
}
|
|
}
|
|
|
|
if len(token.GetSpec().GetRoles()) == 0 {
|
|
return trace.BadParameter("scoped token must have at least one role")
|
|
}
|
|
|
|
if err := validateJoinMethod(token); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var ErrTokenExpired = &trace.LimitExceededError{Message: "scoped token is expired"}
|
|
|
|
var ErrTokenExhausted = &trace.LimitExceededError{Message: "scoped token usage exhausted"}
|
|
|
|
// ValidateTokenForUse checks if a given scoped token can be used for
|
|
// provisioning. Returns a [*trace.LimitExceededError] if the token is expired
|
|
func ValidateTokenForUse(token *joiningv1.ScopedToken, features scopes.Features) error {
|
|
if err := features.AssertEnabled(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if err := StrongValidateToken(token); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
ttl := token.GetMetadata().GetExpires()
|
|
if ttl != nil && !ttl.AsTime().IsZero() {
|
|
if ttl.AsTime().Before(now) {
|
|
return trace.Wrap(ErrTokenExpired)
|
|
}
|
|
}
|
|
|
|
reusableUntil := token.GetStatus().GetUsage().GetSingleUse().GetReusableUntil()
|
|
if reusableUntil != nil && !reusableUntil.AsTime().IsZero() {
|
|
if reusableUntil.AsTime().Before(now) {
|
|
return trace.Wrap(ErrTokenExhausted)
|
|
}
|
|
}
|
|
|
|
// if agent scope pins are enabled, all system roles are allowed to join
|
|
// with a scoped token
|
|
if features.AgentPinEnabled {
|
|
return nil
|
|
}
|
|
|
|
// if agent scope pins are disabled, then we need to ensure that only node and
|
|
// bot roles can be provisioned with a scoped token. Using [slices.ContainsFunc]
|
|
// to make it easier to fail closed
|
|
roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles())
|
|
if err != nil {
|
|
return trace.Wrap(err, "normalizing system roles")
|
|
}
|
|
if !slices.ContainsFunc(roles, func(role types.SystemRole) bool {
|
|
return role != types.RoleNode && role != types.RoleBot
|
|
}) {
|
|
return nil
|
|
}
|
|
|
|
return trace.BadParameter("scoped token cannot be used to join [%s] role(s) without TELEPORT_UNSTABLE_AGENT_SCOPE_PIN=yes", strings.Join(token.GetSpec().GetRoles(), ", "))
|
|
}
|
|
|
|
// ValidateTokenUpdate checks for invalid updates between two tokens.
|
|
// If the scope, usage mode, or secret was changed between two token updates,
|
|
// a trace.BadParameter error is returned.
|
|
func ValidateTokenUpdate(oldToken *joiningv1.ScopedToken, newToken *joiningv1.ScopedToken) error {
|
|
if newToken == nil {
|
|
return trace.BadParameter("new token is invalid")
|
|
}
|
|
// no old token to compare to so we assume that the new token is valid and no need for additional checks
|
|
if oldToken == nil {
|
|
return nil
|
|
}
|
|
tokenName := newToken.GetMetadata().GetName()
|
|
if oldToken.GetScope() != newToken.GetScope() {
|
|
return trace.BadParameter("cannot modify scope of existing scoped token %s with scope %s to %s", tokenName, oldToken.GetScope(), newToken.GetScope())
|
|
}
|
|
|
|
if oldToken.GetSpec().GetUsageMode() != newToken.GetSpec().GetUsageMode() {
|
|
return trace.BadParameter("cannot modify usage mode of existing scoped token %s from usage mode %s to %s", tokenName, oldToken.GetSpec().GetUsageMode(), newToken.GetSpec().GetUsageMode())
|
|
}
|
|
|
|
// If the new Token does not have a status, this should indicate that the status was not sent, meaning no change to the underlying secret
|
|
if newToken.GetStatus() != nil && oldToken.GetStatus().GetSecret() != newToken.GetStatus().GetSecret() {
|
|
return trace.BadParameter("cannot modify secret of existing scoped token %s", tokenName)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Token wraps a [joiningv1.ScopedToken] such that it can be used to provision
|
|
// resources.
|
|
type Token struct {
|
|
scoped *joiningv1.ScopedToken
|
|
joinMethod types.JoinMethod
|
|
roles types.SystemRoles
|
|
botName string
|
|
botScope string
|
|
}
|
|
|
|
// NewToken returns the wrapped version of the given [joiningv1.ScopedToken].
|
|
// It will return an error if the configured join method is not a valid
|
|
// [types.JoinMethod], if any of the configured roles are not a valid
|
|
// [types.SystemRole], or if the bot field is inconsistent with the configured
|
|
// roles. The validated join method, roles, and bot reference are cached on
|
|
// the [Token] wrapper itself so they can be read without repeating
|
|
// validation.
|
|
func NewToken(token *joiningv1.ScopedToken) (*Token, error) {
|
|
joinMethod := types.JoinMethod(token.GetSpec().GetJoinMethod())
|
|
if err := types.ValidateJoinMethod(joinMethod); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
botName, botScope, err := validateBotRef(token.GetSpec(), roles.Include(types.RoleBot))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &Token{
|
|
scoped: token,
|
|
joinMethod: joinMethod,
|
|
roles: roles,
|
|
botName: botName,
|
|
botScope: botScope,
|
|
}, nil
|
|
}
|
|
|
|
// GetName returns the name of a [joiningv1.ScopedToken].
|
|
func (t *Token) GetName() string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
|
|
return t.scoped.GetMetadata().GetName()
|
|
}
|
|
|
|
// GetJoinMethod returns the cached [types.JoinMethod] generated when the
|
|
// [joiningv1.ScopedToken] was wrapped.
|
|
func (t *Token) GetJoinMethod() types.JoinMethod {
|
|
if t == nil {
|
|
return types.JoinMethodUnspecified
|
|
}
|
|
|
|
return t.joinMethod
|
|
}
|
|
|
|
// GetRoles returns the cached [types.SystemRoles] generated when the
|
|
// [joiningv1.ScopedToken] was wrapped.
|
|
func (t *Token) GetRoles() types.SystemRoles {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.roles
|
|
}
|
|
|
|
// GetSafeName returns the name the santiized name of the scoped token. Because
|
|
// scoped token names are not secret, this is just an alias for [GetName].
|
|
func (t *Token) GetSafeName() string {
|
|
return t.GetName()
|
|
}
|
|
|
|
// Expiry returns the [time.Time] representing when the wrapped
|
|
// [joiningv1.ScopedToken] will expire.
|
|
func (t *Token) Expiry() time.Time {
|
|
expiry := t.scoped.GetMetadata().GetExpires()
|
|
if expiry == nil {
|
|
return time.Time{}
|
|
}
|
|
|
|
return expiry.AsTime()
|
|
}
|
|
|
|
// GetBot returns the cached name and scope of the bot that this token can
|
|
// join, validated when the [joiningv1.ScopedToken] was wrapped. An empty name
|
|
// indicates that this is not a bot token; otherwise both components are
|
|
// non-empty.
|
|
func (t *Token) GetBot() (name, scope string) {
|
|
if t == nil {
|
|
return "", ""
|
|
}
|
|
|
|
return t.botName, t.botScope
|
|
}
|
|
|
|
// GetAssignedScope returns the scope that will be assigned to resources
|
|
// provisioned using the wrapped [joiningv1.ScopedToken].
|
|
func (t *Token) GetAssignedScope() string {
|
|
return t.scoped.GetSpec().GetAssignedScope()
|
|
}
|
|
|
|
// GetScope returns the scope of the wrapped [joiningv1.ScopedToken].
|
|
func (t *Token) GetScope() string {
|
|
return t.scoped.GetScope()
|
|
}
|
|
|
|
// GetSecret returns the token's secret value.
|
|
func (t *Token) GetSecret() (string, bool) {
|
|
return t.scoped.GetStatus().GetSecret(), t.GetJoinMethod() == types.JoinMethodToken
|
|
}
|
|
|
|
// GetAllowRules returns the list of allow rules.
|
|
func (t *Token) GetAWSAllowRules() []*types.TokenRule {
|
|
allow := make([]*types.TokenRule, len(t.scoped.GetSpec().GetAws().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetAws().GetAllow() {
|
|
allow[i] = &types.TokenRule{
|
|
AWSAccount: rule.GetAwsAccount(),
|
|
AWSRegions: rule.GetAwsRegions(),
|
|
AWSRole: rule.GetAwsRole(),
|
|
AWSARN: rule.GetAwsArn(),
|
|
AWSOrganizationID: rule.GetAwsOrganizationId(),
|
|
}
|
|
}
|
|
|
|
return allow
|
|
}
|
|
|
|
// GetAWSIIDTTL returns the TTL of EC2 IIDs
|
|
func (t *Token) GetAWSIIDTTL() types.Duration {
|
|
ttl, err := types.ParseDuration(t.scoped.GetSpec().GetAws().GetIidTtl())
|
|
if err != nil {
|
|
// if parsing fails for any reason (including an empty value) we fallback to the 5 minute default
|
|
return types.Duration(5 * time.Minute)
|
|
}
|
|
|
|
return ttl
|
|
}
|
|
|
|
// GetIntegration returns the Integration field which is used to provide
|
|
// credentials that will be used when validating the AWS Organization if required by an IAM Token.
|
|
func (t *Token) GetIntegration() string {
|
|
return t.scoped.GetSpec().GetAws().GetIntegration()
|
|
}
|
|
|
|
// GetGCPRules returns the GCP-specific configuration for this token.
|
|
func (t *Token) GetGCPRules() *types.ProvisionTokenSpecV2GCP {
|
|
allow := make([]*types.ProvisionTokenSpecV2GCP_Rule, len(t.scoped.GetSpec().GetGcp().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetGcp().GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2GCP_Rule{
|
|
ProjectIDs: rule.GetProjectIds(),
|
|
Locations: rule.GetLocations(),
|
|
ServiceAccounts: rule.GetServiceAccounts(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2GCP{
|
|
Allow: allow,
|
|
}
|
|
}
|
|
|
|
// GetAzure returns the Azure-specific configuration for this token.
|
|
func (t *Token) GetAzure() *types.ProvisionTokenSpecV2Azure {
|
|
allow := make([]*types.ProvisionTokenSpecV2Azure_Rule, len(t.scoped.GetSpec().GetAzure().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetAzure().GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2Azure_Rule{
|
|
Tenant: rule.GetTenant(),
|
|
Subscription: rule.GetSubscription(),
|
|
ResourceGroups: rule.GetResourceGroups(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2Azure{
|
|
Allow: allow,
|
|
}
|
|
}
|
|
|
|
// GetAzureDevops returns the AzureDevops-specific configuration for this token.
|
|
func (t *Token) GetAzureDevops() *types.ProvisionTokenSpecV2AzureDevops {
|
|
allow := make([]*types.ProvisionTokenSpecV2AzureDevops_Rule, len(t.scoped.GetSpec().GetAzureDevops().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetAzureDevops().GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2AzureDevops_Rule{
|
|
Sub: rule.GetSub(),
|
|
ProjectName: rule.GetProjectName(),
|
|
PipelineName: rule.GetPipelineName(),
|
|
ProjectID: rule.GetProjectId(),
|
|
DefinitionID: rule.GetDefinitionId(),
|
|
RepositoryURI: rule.GetRepositoryUri(),
|
|
RepositoryVersion: rule.GetRepositoryVersion(),
|
|
RepositoryRef: rule.GetRepositoryRef(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2AzureDevops{
|
|
Allow: allow,
|
|
OrganizationID: t.scoped.GetSpec().GetAzureDevops().GetOrganizationId(),
|
|
}
|
|
}
|
|
|
|
// GetOracle returns the Oracle-specific configuration for this token.
|
|
func (t *Token) GetOracle() *types.ProvisionTokenSpecV2Oracle {
|
|
allow := make([]*types.ProvisionTokenSpecV2Oracle_Rule, len(t.scoped.GetSpec().GetOracle().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetOracle().GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2Oracle_Rule{
|
|
Tenancy: rule.GetTenancy(),
|
|
ParentCompartments: rule.GetParentCompartments(),
|
|
Regions: rule.GetRegions(),
|
|
Instances: rule.GetInstances(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2Oracle{
|
|
Allow: allow,
|
|
}
|
|
}
|
|
|
|
// GetKubernetes returns the Kubernetes-specific configuration for this token.
|
|
func (t *Token) GetKubernetes() *types.ProvisionTokenSpecV2Kubernetes {
|
|
allow := make([]*types.ProvisionTokenSpecV2Kubernetes_Rule, len(t.scoped.GetSpec().GetKubernetes().GetAllow()))
|
|
for i, rule := range t.scoped.GetSpec().GetKubernetes().GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2Kubernetes_Rule{
|
|
ServiceAccount: rule.GetServiceAccount(),
|
|
ServiceAccountName: rule.GetServiceAccountName(),
|
|
ServiceAccountNamespace: rule.GetServiceAccountNamespace(),
|
|
}
|
|
}
|
|
|
|
var staticJWKS *types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig
|
|
if jwks := t.scoped.GetSpec().GetKubernetes().GetStaticJwks().GetJwks(); jwks != "" {
|
|
staticJWKS = &types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig{
|
|
JWKS: jwks,
|
|
}
|
|
}
|
|
|
|
var oidcConfig *types.ProvisionTokenSpecV2Kubernetes_OIDCConfig
|
|
if oidc := t.scoped.GetSpec().GetKubernetes().GetOidc(); oidc != nil {
|
|
oidcConfig = &types.ProvisionTokenSpecV2Kubernetes_OIDCConfig{
|
|
Issuer: oidc.GetIssuer(),
|
|
InsecureAllowHTTPIssuer: oidc.GetInsecureAllowHttpIssuer(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2Kubernetes{
|
|
Allow: allow,
|
|
Type: types.KubernetesJoinType(t.scoped.GetSpec().GetKubernetes().GetType()),
|
|
StaticJWKS: staticJWKS,
|
|
OIDC: oidcConfig,
|
|
}
|
|
}
|
|
|
|
// GetBoundKeypair returns the bound keypair-specific configuration for this
|
|
// token.
|
|
func (t *Token) GetBoundKeypair() *types.ProvisionTokenSpecV2BoundKeypair {
|
|
return BoundKeypairSpecFromScopedToken(t.scoped)
|
|
}
|
|
|
|
// GetBoundKeypairStatus returns the bound keypair-specific status for this
|
|
// token.
|
|
func (t *Token) GetBoundKeypairStatus() *types.ProvisionTokenStatusV2BoundKeypair {
|
|
return BoundKeypairStatusFromScopedToken(t.scoped)
|
|
}
|
|
|
|
// convertGenericOIDCCondition converts a scoped generic_oidc condition to a
|
|
// ProvisionTokenV2-style condition (with gogoproto semantics).
|
|
func convertGenericOIDCCondition(c *joiningv1.GenericOIDC_Condition) (*types.ProvisionTokenSpecV2GenericOIDC_Condition, error) {
|
|
v := &types.ProvisionTokenSpecV2GenericOIDC_Condition{
|
|
Attribute: c.GetAttribute(),
|
|
}
|
|
|
|
switch {
|
|
case c.GetEq() != nil:
|
|
v.Eq = &types.ProvisionTokenSpecV2GenericOIDC_ConditionEq{
|
|
Value: c.GetEq().GetValue(),
|
|
}
|
|
case c.GetNotEq() != nil:
|
|
v.NotEq = &types.ProvisionTokenSpecV2GenericOIDC_ConditionNotEq{
|
|
Value: c.GetNotEq().GetValue(),
|
|
}
|
|
case c.GetIn() != nil:
|
|
v.In = &types.ProvisionTokenSpecV2GenericOIDC_ConditionIn{
|
|
Values: c.GetIn().GetValues(),
|
|
}
|
|
case c.GetNotIn() != nil:
|
|
v.NotIn = &types.ProvisionTokenSpecV2GenericOIDC_ConditionNotIn{
|
|
Values: c.GetNotIn().GetValues(),
|
|
}
|
|
default:
|
|
return nil, trace.BadParameter("an operator is required but found none")
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// GetGenericOIDC returns the generic_oidc-specific configuration for this token.
|
|
func (t *Token) GetGenericOIDC() (*types.ProvisionTokenSpecV2GenericOIDC, error) {
|
|
spec := t.scoped.GetSpec().GetGenericOidc()
|
|
|
|
var globalMatchers *types.Struct
|
|
if gm := spec.GetMustMatchFields(); gm != nil {
|
|
gogo, err := convertStructPB(spec.GetMustMatchFields())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
globalMatchers = gogo
|
|
}
|
|
|
|
allow := make([]*types.ProvisionTokenSpecV2GenericOIDC_Rule, len(spec.GetAllowAny()))
|
|
for i, rule := range spec.GetAllowAny() {
|
|
conditions := make([]*types.ProvisionTokenSpecV2GenericOIDC_Condition, len(rule.GetConditions()))
|
|
for j, condition := range rule.GetConditions() {
|
|
converted, err := convertGenericOIDCCondition(condition)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
conditions[j] = converted
|
|
}
|
|
|
|
allow[i] = &types.ProvisionTokenSpecV2GenericOIDC_Rule{
|
|
Expression: rule.GetExpression(),
|
|
Conditions: conditions,
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2GenericOIDC{
|
|
Issuer: spec.GetIssuer(),
|
|
InsecureAllowHTTPIssuer: spec.GetInsecureAllowHttpIssuer(),
|
|
Audience: spec.GetAudience(),
|
|
StaticJWKS: spec.GetStaticJwks(),
|
|
TLSCA: spec.GetTlsCa(),
|
|
|
|
MustMatchFields: globalMatchers,
|
|
AllowAny: allow,
|
|
}, nil
|
|
}
|
|
|
|
func (t *Token) GetGithub() *types.ProvisionTokenSpecV2GitHub {
|
|
spec := t.scoped.GetSpec().GetGithub()
|
|
|
|
allow := make([]*types.ProvisionTokenSpecV2GitHub_Rule, len(spec.GetAllow()))
|
|
for i, rule := range spec.GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2GitHub_Rule{
|
|
Sub: rule.GetSub(),
|
|
Repository: rule.GetRepository(),
|
|
RepositoryOwner: rule.GetRepositoryOwner(),
|
|
Workflow: rule.GetWorkflow(),
|
|
Environment: rule.GetEnvironment(),
|
|
Actor: rule.GetActor(),
|
|
Ref: rule.GetRef(),
|
|
RefType: rule.GetRefType(),
|
|
Enterprise: rule.GetEnterprise(),
|
|
EnterpriseID: rule.GetEnterpriseId(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2GitHub{
|
|
EnterpriseServerHost: spec.GetEnterpriseServerHost(),
|
|
EnterpriseSlug: spec.GetEnterpriseSlug(),
|
|
StaticJWKS: spec.GetStaticJwks(),
|
|
Allow: allow,
|
|
}
|
|
}
|
|
|
|
func (t *Token) GetGitLab() *types.ProvisionTokenSpecV2GitLab {
|
|
spec := t.scoped.GetSpec().GetGitlab()
|
|
|
|
allow := make([]*types.ProvisionTokenSpecV2GitLab_Rule, len(spec.GetAllow()))
|
|
for i, rule := range spec.GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2GitLab_Rule{
|
|
Sub: rule.GetSub(),
|
|
Ref: rule.GetRef(),
|
|
RefType: rule.GetRefType(),
|
|
NamespacePath: rule.GetNamespacePath(),
|
|
ProjectPath: rule.GetProjectPath(),
|
|
PipelineSource: rule.GetPipelineSource(),
|
|
Environment: rule.GetEnvironment(),
|
|
UserLogin: rule.GetUserLogin(),
|
|
UserID: rule.GetUserId(),
|
|
UserEmail: rule.GetUserEmail(),
|
|
CIConfigSHA: rule.GetCiConfigSha(),
|
|
CIConfigRefURI: rule.GetCiConfigRefUri(),
|
|
DeploymentTier: rule.GetDeploymentTier(),
|
|
ProjectVisibility: rule.GetProjectVisibility(),
|
|
}
|
|
if rule.HasRefProtected() {
|
|
allow[i].RefProtected = types.NewBoolOption(rule.GetRefProtected())
|
|
}
|
|
if rule.HasEnvironmentProtected() {
|
|
allow[i].EnvironmentProtected = types.NewBoolOption(rule.GetEnvironmentProtected())
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2GitLab{
|
|
Domain: spec.GetDomain(),
|
|
StaticJWKS: spec.GetStaticJwks(),
|
|
Allow: allow,
|
|
}
|
|
}
|
|
|
|
// GetTPM returns the TPM configuration for this token. Returns an empty but
|
|
// not nil value if TPM was not configured.
|
|
func (t *Token) GetTPM() *types.ProvisionTokenSpecV2TPM {
|
|
spec := t.scoped.GetSpec().GetTpm()
|
|
|
|
allow := make([]*types.ProvisionTokenSpecV2TPM_Rule, len(spec.GetAllow()))
|
|
for i, rule := range spec.GetAllow() {
|
|
allow[i] = &types.ProvisionTokenSpecV2TPM_Rule{
|
|
Description: rule.GetDescription(),
|
|
EKPublicHash: rule.GetEkPublicHash(),
|
|
EKCertificateSerial: rule.GetEkCertificateSerial(),
|
|
}
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2TPM{
|
|
Allow: allow,
|
|
EKCertAllowedCAs: spec.GetEkcertAllowedCas(),
|
|
}
|
|
}
|
|
|
|
// GetScoped returns the inner scoped token wrapped by this [provision.Token].
|
|
func (t *Token) GetScoped() *joiningv1.ScopedToken {
|
|
return t.scoped
|
|
}
|
|
|
|
// GetScopedToken attempts to return the underlying [*joiningv1.ScopedToken] backing a
|
|
// [provision.Token]. Returns a boolean indicating whether the token is scoped or not.
|
|
func GetScopedToken(token provision.Token) (*joiningv1.ScopedToken, bool) {
|
|
wrapper, ok := token.(*Token)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
return wrapper.scoped, true
|
|
}
|
|
|
|
// GetImmutableLabels returns labels that must be applied to resources
|
|
// provisioned with this token.
|
|
func (t *Token) GetImmutableLabels() *joiningv1.ImmutableLabels {
|
|
return t.scoped.GetSpec().GetImmutableLabels()
|
|
}
|
|
|
|
// BoundKeypairSpecFromScopedToken converts the bound keypair spec from a
|
|
// [*joiningv1.ScopedToken] to the equivalent
|
|
// [*types.ProvisionTokenSpecV2BoundKeypair].
|
|
func BoundKeypairSpecFromScopedToken(token *joiningv1.ScopedToken) *types.ProvisionTokenSpecV2BoundKeypair {
|
|
spec := token.GetSpec().GetBoundKeypair()
|
|
|
|
var mustRegisterBefore, rotateAfter *time.Time
|
|
if m := spec.GetOnboarding().GetMustRegisterBefore(); m != nil {
|
|
t := m.AsTime()
|
|
mustRegisterBefore = &t
|
|
}
|
|
if v := spec.GetRotateAfter(); v != nil {
|
|
t := v.AsTime()
|
|
rotateAfter = &t
|
|
}
|
|
|
|
return &types.ProvisionTokenSpecV2BoundKeypair{
|
|
Onboarding: &types.ProvisionTokenSpecV2BoundKeypair_OnboardingSpec{
|
|
RegistrationSecret: spec.GetOnboarding().GetRegistrationSecret(),
|
|
InitialPublicKey: spec.GetOnboarding().GetInitialPublicKey(),
|
|
MustRegisterBefore: mustRegisterBefore,
|
|
},
|
|
Recovery: &types.ProvisionTokenSpecV2BoundKeypair_RecoverySpec{
|
|
Limit: spec.GetRecovery().GetLimit(),
|
|
Mode: spec.GetRecovery().GetMode(),
|
|
},
|
|
RotateAfter: rotateAfter,
|
|
}
|
|
}
|
|
|
|
// BoundKeypairStatusFromScopedToken converts the bound keypair status from a
|
|
// [*joiningv1.ScopedToken] to the equivalent
|
|
// [*types.ProvisionTokenStatusV2BoundKeypair].
|
|
func BoundKeypairStatusFromScopedToken(token *joiningv1.ScopedToken) *types.ProvisionTokenStatusV2BoundKeypair {
|
|
status := token.GetStatus().GetUsage().GetBoundKeypair()
|
|
|
|
var lastRecoveredAt, lastRotatedAt *time.Time
|
|
if val := status.GetLastRecoveredAt(); val != nil {
|
|
v := val.AsTime()
|
|
lastRecoveredAt = &v
|
|
}
|
|
if val := status.GetLastRotatedAt(); val != nil {
|
|
v := val.AsTime()
|
|
lastRotatedAt = &v
|
|
}
|
|
|
|
return &types.ProvisionTokenStatusV2BoundKeypair{
|
|
RegistrationSecret: status.GetRegistrationSecret(),
|
|
BoundPublicKey: status.GetBoundPublicKey(),
|
|
BoundBotInstanceID: status.GetBoundBotInstanceId(),
|
|
BoundHostID: status.GetBoundHostId(),
|
|
RecoveryCount: status.GetRecoveryCount(),
|
|
LastRecoveredAt: lastRecoveredAt,
|
|
LastRotatedAt: lastRotatedAt,
|
|
}
|
|
}
|
|
|
|
func validateImmutableLabels(spec *joiningv1.ScopedTokenSpec) error {
|
|
if spec == nil {
|
|
return nil
|
|
}
|
|
|
|
sshLabels := spec.GetImmutableLabels().GetSsh()
|
|
if len(sshLabels) > 0 {
|
|
if !slices.Contains(spec.GetRoles(), string(types.RoleNode)) {
|
|
return trace.BadParameter("immutable ssh labels are only supported for tokens that allow the node role")
|
|
}
|
|
}
|
|
|
|
for k := range sshLabels {
|
|
if !types.IsValidLabelKey(k) {
|
|
return trace.BadParameter("invalid immutable label key %q", k)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HashImmutableLabels returns a deterministic hash of the given [*joiningv1.ImmutableLabels].
|
|
func HashImmutableLabels(labels *joiningv1.ImmutableLabels) string {
|
|
if labels == nil {
|
|
return ""
|
|
}
|
|
|
|
hash := sha256.New()
|
|
var bytesWrittenToHash int
|
|
writeHash := func(p []byte) {
|
|
n, _ := hash.Write(p)
|
|
bytesWrittenToHash += n
|
|
}
|
|
|
|
if sshLabels := labels.GetSsh(); len(sshLabels) > 0 {
|
|
sorted := make([]struct{ key, value string }, 0, len(sshLabels))
|
|
for k, v := range sshLabels {
|
|
sorted = append(sorted, struct{ key, value string }{k, v})
|
|
}
|
|
slices.SortFunc(sorted, func(a, b struct{ key, value string }) int {
|
|
return cmp.Compare(a.key, b.key)
|
|
})
|
|
|
|
// first we write the service type so that the following labels do not collide with identical labels
|
|
// from other services e.g. app labels or database labels
|
|
writeHash([]byte("ssh"))
|
|
|
|
// Each map entry is added to the hash as 4 components:
|
|
// 1. The length of the key
|
|
// 2. The value of the key
|
|
// 3. The length of the value
|
|
// 4. The value itself
|
|
// This combination prevents collisions between:
|
|
// - single labels (e.g. aaa=bbb and aaab=bb)
|
|
// - splitting labels (e.g. aaa=bbbcccddd and aaa=bbb,ccc=ddd)
|
|
// ...because in both cases the lengths of the keys/values must change to create different labels from
|
|
// the same set of characters.
|
|
for _, v := range sorted {
|
|
buf := [8]byte{}
|
|
binary.BigEndian.PutUint64(buf[:], uint64(len(v.key)))
|
|
writeHash(buf[:])
|
|
writeHash([]byte(v.key))
|
|
binary.BigEndian.PutUint64(buf[:], uint64(len(v.value)))
|
|
writeHash(buf[:])
|
|
writeHash([]byte(v.value))
|
|
}
|
|
}
|
|
|
|
if bytesWrittenToHash == 0 {
|
|
return ""
|
|
}
|
|
|
|
return hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
// VerifyImmutableLabelsHash returns whether or not the given [*joiningv1.ImmutableLabels]
|
|
// matches the given hash.
|
|
func VerifyImmutableLabelsHash(labels *joiningv1.ImmutableLabels, hash string) bool {
|
|
newHash := HashImmutableLabels(labels)
|
|
return newHash == hash
|
|
}
|
|
|
|
const tokenNameAndSecretSeparator = ":"
|
|
|
|
// EncodeScopedToken combines a token name and secret into a single encoded value. The encoded
|
|
// tokens are used when providing tokens externally to end users to ease UX.
|
|
func EncodeScopedToken(name, secret string) string {
|
|
return name + tokenNameAndSecretSeparator + base64.RawURLEncoding.EncodeToString([]byte(secret))
|
|
}
|
|
|
|
// DecodeScopedToken produces a token name and secret from an encoded value. If the token
|
|
// is not encoded the return value is token, "", false.
|
|
func DecodeScopedToken(token string) (name string, secret string, ok bool) {
|
|
name, base64Secret, ok := strings.Cut(token, tokenNameAndSecretSeparator)
|
|
if !ok {
|
|
return token, "", false
|
|
}
|
|
|
|
s, err := base64.RawURLEncoding.DecodeString(base64Secret)
|
|
if err != nil {
|
|
return token, "", false
|
|
}
|
|
|
|
return name, string(s), true
|
|
}
|