diff --git a/CHANGELOG.md b/CHANGELOG.md index 4547c357e6..7f289220a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. ## Next +### New and Improved + +* Vault Parameter Templating: In `vault` credential libraries, the paths and any + POST bodies can contain templated parameters using Go template syntax (similar + to Consul-Template). The following template parameters are supported (note + that account values are tied to the account associated with the token making + the call): + * `{{ .User.Id }}`: the user's ID + * `{{ .User.Name }}`: the user's name (from the user resource) + * `{{ .User.FullName }}`: the user's name (from the account corresponding to + theprimary auth method in the user's scope; this may not be populated or + maybe different than the account name in the template) + * `{{ .User.Email }}`: the user's email address (same caveat as `FullName`) + * `{{ .Account.Id }}`: the account's ID + * `{{ .Account.Name }}`: the account's name (from the account resource) + * `{{ .Account.LoginName }}`: the account's login name (if used by that type + of ccount) + * `{{ .Account.Subject }}`: the account's subject (if used by that type + of ccount) + * `{{ .Account.Email }}`: the account's email (if used by that type + of account) + + Additionally, there is currently a single function that strips the rest of a + string after a specified substring; this is useful for pulling an user/account name from an email address. In the following example it uses the account email can be any other parameter: + + * `{{ truncateFrom .Account.Email "@" }}`: this would turn `foo@example.com` into `foo` + ### Bug Fixes * accounts: Deleted auth accounts would still show up as being associated with a @@ -19,6 +46,13 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. * workers: Fixed a panic that can happen in certain situations ([PR](https://github.com/hashicorp/boundary/pull/2553)) +### Deprecations/Changes + +* In order to standardize on the templating format, [templates in + grants](https://developer.hashicorp.com/boundary/docs/concepts/security/permissions/permission-grant-formats#templates) + now are documented to use the new capitalization and format; however, the + previous style will continue to work. + ## 0.11.0 (2022/09/27) ### Known Issues diff --git a/internal/cmd/base/initial_resources.go b/internal/cmd/base/initial_resources.go index dddbdf23c0..18ed1813c6 100644 --- a/internal/cmd/base/initial_resources.go +++ b/internal/cmd/base/initial_resources.go @@ -52,7 +52,7 @@ func (b *Server) CreateInitialLoginRole(ctx context.Context) (*iam.Role, error) if _, err := iamRepo.AddRoleGrants(ctx, role.PublicId, role.Version, []string{ "id=*;type=scope;actions=list,no-op", "id=*;type=auth-method;actions=authenticate,list", - "id={{account.id}};actions=read,change-password", + "id={{.Account.Id}};actions=read,change-password", "id=*;type=auth-token;actions=list,read:self,delete:self", }); err != nil { return nil, fmt.Errorf("error creating grant for default generated grants: %w", err) diff --git a/internal/credential/credential.go b/internal/credential/credential.go index 09218994da..0c8726eef7 100644 --- a/internal/credential/credential.go +++ b/internal/credential/credential.go @@ -106,7 +106,9 @@ type Issuer interface { // // If Issue encounters an error, it returns no credentials and revokes // any credentials issued before encountering the error. - Issue(ctx context.Context, sessionId string, requests []Request) ([]Dynamic, error) + // + // Supported Options: WithTemplateData + Issue(ctx context.Context, sessionId string, requests []Request, opt ...Option) ([]Dynamic, error) } // Revoker revokes dynamic credentials. diff --git a/internal/credential/options.go b/internal/credential/options.go new file mode 100644 index 0000000000..9db8c84aaf --- /dev/null +++ b/internal/credential/options.go @@ -0,0 +1,39 @@ +package credential + +import ( + "github.com/hashicorp/boundary/internal/util/template" +) + +// GetOpts - iterate the inbound Options and return a struct +func GetOpts(opt ...Option) (*options, error) { + opts := getDefaultOptions() + for _, o := range opt { + if o == nil { + continue + } + if err := o(opts); err != nil { + return nil, err + } + } + return opts, nil +} + +// Option - how Options are passed as arguments. +type Option func(*options) error + +// options = how options are represented +type options struct { + WithTemplateData template.Data +} + +func getDefaultOptions() *options { + return &options{} +} + +// WithTemplateData provides a way to pass in template information +func WithTemplateData(with template.Data) Option { + return func(o *options) error { + o.WithTemplateData = with + return nil + } +} diff --git a/internal/credential/options_test.go b/internal/credential/options_test.go new file mode 100644 index 0000000000..35c32fd469 --- /dev/null +++ b/internal/credential/options_test.go @@ -0,0 +1,21 @@ +package credential + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/internal/util/template" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("WithTemplateData", func(t *testing.T) { + opts := getDefaultOptions() + assert.Empty(t, opts.WithTemplateData) + opts, err := GetOpts(WithTemplateData(template.Data{User: template.User{Id: util.Pointer("foo")}})) + require.NoError(t, err) + assert.Equal(t, "foo", *opts.WithTemplateData.User.Id) + }) +} diff --git a/internal/credential/vault/private_library.go b/internal/credential/vault/private_library.go index d6c57311cf..2741941263 100644 --- a/internal/credential/vault/private_library.go +++ b/internal/credential/vault/private_library.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/util/template" wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping" vault "github.com/hashicorp/vault/api" @@ -289,7 +290,14 @@ type dynamicCred interface { // retrieveCredential retrieves a dynamic credential from Vault for the // given sessionId. -func (pl *issueCredentialLibrary) retrieveCredential(ctx context.Context, op errors.Op, sessionId string) (dynamicCred, error) { +// +// Supported options: credential.WithTemplateData +func (pl *issueCredentialLibrary) retrieveCredential(ctx context.Context, op errors.Op, sessionId string, opt ...credential.Option) (dynamicCred, error) { + opts, err := credential.GetOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + // Get the credential ID early. No need to get a secret from Vault // if there is no way to save it in the database. credId, err := newCredentialId() @@ -303,19 +311,47 @@ func (pl *issueCredentialLibrary) retrieveCredential(ctx context.Context, op err } var secret *vault.Secret + var reqErr error + + // Template the path + path := pl.VaultPath + if path != "" { + parsedTmpl, err := template.New(ctx, path) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + path, err = parsedTmpl.Generate(ctx, opts.WithTemplateData) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + } + + // Template the body + body := string(pl.HttpRequestBody) + if body != "" { + parsedTmpl, err := template.New(ctx, body) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + body, err = parsedTmpl.Generate(ctx, opts.WithTemplateData) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + } + switch Method(pl.HttpMethod) { case MethodGet: - secret, err = client.get(ctx, pl.VaultPath) + secret, reqErr = client.get(ctx, path) case MethodPost: - secret, err = client.post(ctx, pl.VaultPath, pl.HttpRequestBody) + secret, reqErr = client.post(ctx, path, []byte(body)) default: return nil, errors.New(ctx, errors.Internal, op, fmt.Sprintf("unknown http method: library: %s", pl.PublicId)) } - if err != nil { + if reqErr != nil { // TODO(mgaffney) 05/2021: detect if the error is because of an // expired or invalid token - return nil, errors.Wrap(ctx, err, op) + return nil, errors.Wrap(ctx, reqErr, op) } if secret == nil { return nil, errors.E(ctx, errors.WithCode(errors.VaultEmptySecret), errors.WithOp(op)) diff --git a/internal/credential/vault/repository_credentials.go b/internal/credential/vault/repository_credentials.go index 33210db39a..8d72895ac9 100644 --- a/internal/credential/vault/repository_credentials.go +++ b/internal/credential/vault/repository_credentials.go @@ -15,7 +15,7 @@ var _ credential.Issuer = (*Repository)(nil) // Issue issues and returns dynamic credentials from Vault for all of the // requests and assigns them to sessionId. -func (r *Repository) Issue(ctx context.Context, sessionId string, requests []credential.Request) ([]credential.Dynamic, error) { +func (r *Repository) Issue(ctx context.Context, sessionId string, requests []credential.Request, opt ...credential.Option) ([]credential.Dynamic, error) { const op = "vault.(Repository).Issue" if sessionId == "" { return nil, errors.New(ctx, errors.InvalidParameter, op, "no session id") @@ -37,7 +37,7 @@ func (r *Repository) Issue(ctx context.Context, sessionId string, requests []cre var minLease time.Duration runJobsInterval := r.scheduler.GetRunJobsInterval() for _, lib := range libs { - cred, err := lib.retrieveCredential(ctx, op, sessionId) + cred, err := lib.retrieveCredential(ctx, op, sessionId, opt...) if err != nil { return nil, err } diff --git a/internal/daemon/controller/auth/auth.go b/internal/daemon/controller/auth/auth.go index 6331b1ba11..8714d2e32d 100644 --- a/internal/daemon/controller/auth/auth.go +++ b/internal/daemon/controller/auth/auth.go @@ -10,13 +10,15 @@ import ( "github.com/hashicorp/boundary/api/recovery" "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/auth/oidc" + "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/daemon/controller/common" + "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/errors" authpb "github.com/hashicorp/boundary/internal/gen/controller/auth" "github.com/hashicorp/boundary/internal/gen/controller/tokens" "github.com/hashicorp/boundary/internal/iam" - - "github.com/hashicorp/boundary/internal/daemon/controller/common" - "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/observability/event" "github.com/hashicorp/boundary/internal/perms" @@ -25,6 +27,9 @@ import ( "github.com/hashicorp/boundary/internal/types/action" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/boundary/internal/types/subtypes" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/internal/util/template" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes" wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/mr-tron/base58" @@ -56,6 +61,9 @@ type key int var verifierKey key type VerifyResults struct { + UserData template.Data + // This is copied out from UserData above but used to avoid nil checks in + // lots of places that embed this value UserId string AuthTokenId string Error error @@ -92,15 +100,47 @@ type VerifyResults struct { } type verifier struct { - iamRepoFn common.IamRepoFactory - authTokenRepoFn common.AuthTokenRepoFactory - serversRepoFn common.ServersRepoFactory - kms *kms.Kms - requestInfo *authpb.RequestInfo - res *perms.Resource - act action.Type - ctx context.Context - acl perms.ACL + iamRepoFn common.IamRepoFactory + authTokenRepoFn common.AuthTokenRepoFactory + serversRepoFn common.ServersRepoFactory + passwordAuthRepoFn common.PasswordAuthRepoFactory + oidcAuthRepoFn common.OidcAuthRepoFactory + kms *kms.Kms + requestInfo *authpb.RequestInfo + res *perms.Resource + act action.Type + ctx context.Context + acl perms.ACL +} + +// TODO (jefferai 10/2022): NewVerifierContextWithAccounts performs the function +// of NewVerifierContext (see the docs for that function) but with extra +// parameters that can be used to look up account information. This is not +// intended to be a long-lived function; see +// https://hashicorp.atlassian.net/browse/ICU-6571 and +// https://hashicorp.atlassian.net/browse/ICU-6572 +// +// This is being added for a quick turnaround purpose and to avoid making large +// numbers of changes to tests when we may do a much bigger refactor; when those +// items are addressed this can be removed. +func NewVerifierContextWithAccounts(ctx context.Context, + iamRepoFn common.IamRepoFactory, + authTokenRepoFn common.AuthTokenRepoFactory, + serversRepoFn common.ServersRepoFactory, + passwordAuthRepoFn common.PasswordAuthRepoFactory, + oidcAuthRepoFn common.OidcAuthRepoFactory, + kms *kms.Kms, + requestInfo *authpb.RequestInfo, +) context.Context { + return context.WithValue(ctx, verifierKey, &verifier{ + iamRepoFn: iamRepoFn, + authTokenRepoFn: authTokenRepoFn, + serversRepoFn: serversRepoFn, + passwordAuthRepoFn: passwordAuthRepoFn, + oidcAuthRepoFn: oidcAuthRepoFn, + kms: kms, + requestInfo: requestInfo, + }) } // NewVerifierContext creates a context that carries a verifier object from the @@ -114,13 +154,7 @@ func NewVerifierContext(ctx context.Context, kms *kms.Kms, requestInfo *authpb.RequestInfo, ) context.Context { - return context.WithValue(ctx, verifierKey, &verifier{ - iamRepoFn: iamRepoFn, - authTokenRepoFn: authTokenRepoFn, - serversRepoFn: serversRepoFn, - kms: kms, - requestInfo: requestInfo, - }) + return NewVerifierContextWithAccounts(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, nil, nil, kms, requestInfo) } // Verify takes in a context that has expected parameters as values and runs an @@ -209,6 +243,7 @@ func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) { } } ret.UserId = v.requestInfo.UserIdOverride + ret.UserData.User.Id = util.Pointer(ret.UserId) if reqInfo != nil { reqInfo.UserId = ret.UserId } @@ -235,14 +270,17 @@ func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) { var authResults perms.ACLResults var grantTuples []perms.GrantTuple - var accountId, userName, userEmail string + var userData template.Data var err error - authResults, ret.UserId, accountId, userName, userEmail, ret.Scope, v.acl, grantTuples, err = v.performAuthCheck(ctx) + authResults, ret.UserData, ret.Scope, v.acl, grantTuples, err = v.performAuthCheck(ctx) if err != nil { event.WriteError(ctx, op, err, event.WithInfoMsg("error performing authn/authz check")) return } + if ret.UserData.User.Id != nil { + ret.UserId = *ret.UserData.User.Id + } ret.AuthTokenId = v.requestInfo.PublicId ret.AuthenticationFinished = authResults.AuthenticationFinished if !authResults.Authorized { @@ -288,14 +326,20 @@ func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) { }) } ea.UserInfo = &event.UserInfo{ - UserId: ret.UserId, - AuthAccountId: accountId, + UserId: ret.UserId, + } + if userData.Account.Id != nil { + ea.UserInfo.AuthAccountId = *userData.Account.Id } ea.GrantsInfo = &event.GrantsInfo{ Grants: grants, } - ea.UserName = userName - ea.UserEmail = userEmail + if userData.User.FullName != nil { + ea.UserName = *userData.User.FullName + } + if userData.User.Email != nil { + ea.UserEmail = *userData.User.Email + } if reqInfo != nil { reqInfo.UserId = ret.UserId @@ -434,10 +478,7 @@ func (v *verifier) decryptToken(ctx context.Context) { func (v verifier) performAuthCheck(ctx context.Context) ( aclResults perms.ACLResults, - userId string, - accountId string, - userName string, - userEmail string, + userData template.Data, scopeInfo *scopes.ScopeInfo, retAcl perms.ACL, grantTuples []perms.GrantTuple, @@ -449,7 +490,10 @@ func (v verifier) performAuthCheck(ctx context.Context) ( // Make the linter happy _ = retErr scopeInfo = new(scopes.ScopeInfo) - userId = AnonymousUserId + + // This will always be set, so further down below we can switch on whether + // it's empty + userData.User.Id = util.Pointer(AnonymousUserId) // Validate the token and fetch the corresponding user ID switch v.requestInfo.TokenFormat { @@ -459,7 +503,7 @@ func (v verifier) performAuthCheck(ctx context.Context) ( case uint32(AuthTokenTypeRecoveryKms): // We validated the encrypted token in decryptToken and handled the // nonces there, so just set the user - userId = "u_recovery" + userData.User.Id = util.Pointer("u_recovery") case uint32(AuthTokenTypeBearer), uint32(AuthTokenTypeSplitCookie): if v.requestInfo.Token == "" { @@ -479,12 +523,12 @@ func (v verifier) performAuthCheck(ctx context.Context) ( break } if at != nil { - accountId = at.GetAuthAccountId() - userId = at.GetIamUserId() - if userId == "" { + userData.Account.Id = util.Pointer(at.GetAuthAccountId()) + userData.User.Id = util.Pointer(at.GetIamUserId()) + if *userData.User.Id == "" { event.WriteError(ctx, op, stderrors.New("perform auth check: valid token did not map to a user, likely because no account is associated with the user any longer; continuing as u_anon"), event.WithInfo("token_id", at.GetPublicId())) - userId = AnonymousUserId - accountId = "" + userData.User.Id = util.Pointer(AnonymousUserId) + userData.Account.Id = nil } } } @@ -495,13 +539,51 @@ func (v verifier) performAuthCheck(ctx context.Context) ( return } - u, _, err := iamRepo.LookupUser(ctx, userId) + u, _, err := iamRepo.LookupUser(ctx, *userData.User.Id) if err != nil { retErr = errors.Wrap(ctx, err, op, errors.WithMsg("failed to lookup user")) return } - userEmail = u.Email - userName = u.FullName + userData.User.Name = util.Pointer(u.Name) + userData.User.Email = util.Pointer(u.Email) + userData.User.FullName = util.Pointer(u.FullName) + + if userData.Account.Id != nil && *userData.Account.Id != "" && v.passwordAuthRepoFn != nil && v.oidcAuthRepoFn != nil { + const domain = "auth" + var acct auth.Account + var err error + switch subtypes.SubtypeFromId(domain, *userData.Account.Id) { + case password.Subtype: + repo, repoErr := v.passwordAuthRepoFn() + if repoErr != nil { + retErr = errors.Wrap(ctx, repoErr, op, errors.WithMsg("failed to get password auth repo")) + return + } + acct, err = repo.LookupAccount(ctx, *userData.Account.Id) + case oidc.Subtype: + repo, repoErr := v.oidcAuthRepoFn() + if repoErr != nil { + retErr = errors.Wrap(ctx, repoErr, op, errors.WithMsg("failed to get oidc auth repo")) + return + } + acct, err = repo.LookupAccount(ctx, *userData.Account.Id) + default: + retErr = errors.Wrap(ctx, err, op, errors.WithMsg("unrecognized account id type")) + return + } + if err != nil { + if errors.IsNotFoundError(err) { + retErr = errors.Wrap(ctx, err, op, errors.WithMsg("account doesn't exist")) + return + } + retErr = errors.Wrap(ctx, err, op, errors.WithMsg("error looking up account")) + return + } + userData.Account.Name = util.Pointer(acct.GetName()) + userData.Account.Email = util.Pointer(acct.GetEmail()) + userData.Account.LoginName = util.Pointer(acct.GetLoginName()) + userData.Account.Subject = util.Pointer(acct.GetSubject()) + } // Look up scope details to return. We can skip a lookup when using the // global scope @@ -546,7 +628,7 @@ func (v verifier) performAuthCheck(ctx context.Context) ( // Fetch and parse grants for this user ID (which may include grants for // u_anon and u_auth) - grantTuples, err = iamRepo.GrantsForUser(v.ctx, userId) + grantTuples, err = iamRepo.GrantsForUser(v.ctx, *userData.User.Id) if err != nil { retErr = errors.Wrap(ctx, err, op) return @@ -556,12 +638,17 @@ func (v verifier) performAuthCheck(ctx context.Context) ( // that we've since restricted, e.g. "id=foo;actions=create,read". These // will simply not have an effect. for _, pair := range grantTuples { + permsOpts := []perms.Option{ + perms.WithUserId(*userData.User.Id), + perms.WithSkipFinalValidation(true), + } + if userData.Account.Id != nil { + permsOpts = append(permsOpts, perms.WithAccountId(*userData.Account.Id)) + } parsed, err := perms.Parse( pair.ScopeId, pair.Grant, - perms.WithUserId(userId), - perms.WithAccountId(accountId), - perms.WithSkipFinalValidation(true)) + permsOpts...) if err != nil { retErr = errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed to parse grant %#v", pair.Grant))) return @@ -570,7 +657,7 @@ func (v verifier) performAuthCheck(ctx context.Context) ( } retAcl = perms.NewACL(parsedGrants...) - aclResults = retAcl.Allowed(*v.res, v.act, userId) + aclResults = retAcl.Allowed(*v.res, v.act, *userData.User.Id) // We don't set authenticated above because setting this but not authorized // is used for further permissions checks, such as during recursive listing. // So we want to make sure any code relying on that has the full set of @@ -601,6 +688,13 @@ func (r *VerifyResults) fetchActions(id string, typ resource.Type, availableActi return availableActions } + // If there is no user ID set by definition there are no actions to fetch. + // This shouldn't happen because we should always fall back to at least the + // anonymous user so it's defense in depth. + if r.UserData.User.Id == nil { + return nil + } + opts := getOpts(opt...) res := opts.withResource // If not passed in, use what's already been populated through verification @@ -620,7 +714,7 @@ func (r *VerifyResults) fetchActions(id string, typ resource.Type, availableActi ret := make(action.ActionSet, 0, len(availableActions)) for _, act := range availableActions { - if r.v.acl.Allowed(*res, act, r.UserId).Authorized { + if r.v.acl.Allowed(*res, act, *r.UserData.User.Id).Authorized { ret = append(ret, act) } } @@ -636,9 +730,14 @@ func (r *VerifyResults) FetchOutputFields(res perms.Resource, act action.Type) p return perms.OutputFieldsMap{"*": true} case r.v.requestInfo.DisableAuthEntirely: return nil + case r.UserData.User.Id == nil: + // If there is no user ID set by definition there are no actions to fetch. + // This shouldn't happen because we should always fall back to at least the + // anonymous user so it's defense in depth. + return nil } - return r.v.acl.Allowed(res, act, r.UserId).OutputFields + return r.v.acl.Allowed(res, act, *r.UserData.User.Id).OutputFields } // ACL returns the perms.ACL of the verifier. diff --git a/internal/daemon/controller/gateway.go b/internal/daemon/controller/gateway.go index 56df5c94db..15b5a8716b 100644 --- a/internal/daemon/controller/gateway.go +++ b/internal/daemon/controller/gateway.go @@ -57,6 +57,8 @@ func newGrpcServer( iamRepoFn common.IamRepoFactory, authTokenRepoFn common.AuthTokenRepoFactory, serversRepoFn common.ServersRepoFactory, + passwordAuthRepoFn common.PasswordAuthRepoFactory, + oidcAuthRepoFn common.OidcAuthRepoFactory, kms *kms.Kms, eventer *event.Eventer, ) (*grpc.Server, string, error) { @@ -65,7 +67,7 @@ func newGrpcServer( if err != nil { return nil, "", errors.Wrap(ctx, err, op, errors.WithMsg("unable to generate gateway ticket")) } - requestCtxInterceptor, err := requestCtxInterceptor(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, kms, ticket, eventer) + requestCtxInterceptor, err := requestCtxInterceptor(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, passwordAuthRepoFn, oidcAuthRepoFn, kms, ticket, eventer) if err != nil { return nil, "", err } diff --git a/internal/daemon/controller/handlers/targets/target_service.go b/internal/daemon/controller/handlers/targets/target_service.go index e2a59aac1f..0a03d03d5b 100644 --- a/internal/daemon/controller/handlers/targets/target_service.go +++ b/internal/daemon/controller/handlers/targets/target_service.go @@ -853,7 +853,7 @@ func (s Service) AuthorizeSession(ctx context.Context, req *pbs.AuthorizeSession if err != nil { return nil, errors.Wrap(ctx, err, op) } - dynamic, err = credRepo.Issue(ctx, sess.GetPublicId(), vaultReqs) + dynamic, err = credRepo.Issue(ctx, sess.GetPublicId(), vaultReqs, credential.WithTemplateData(authResults.UserData)) if err != nil { return nil, errors.Wrap(ctx, err, op) } diff --git a/internal/daemon/controller/handlers/targets/tcp/target_service_test.go b/internal/daemon/controller/handlers/targets/tcp/target_service_test.go index 0918e3835f..9c1e35920a 100644 --- a/internal/daemon/controller/handlers/targets/tcp/target_service_test.go +++ b/internal/daemon/controller/handlers/targets/tcp/target_service_test.go @@ -2,8 +2,10 @@ package tcp_test import ( "context" + "crypto/x509" "encoding/base64" "encoding/json" + "encoding/pem" "errors" "fmt" "path" @@ -11,6 +13,8 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/boundary/internal/auth/oidc" + "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/authtoken" "github.com/hashicorp/boundary/internal/credential" credstatic "github.com/hashicorp/boundary/internal/credential/static" @@ -2260,6 +2264,12 @@ func TestAuthorizeSession(t *testing.T) { atRepoFn := func() (*authtoken.Repository, error) { return authtoken.NewRepository(rw, rw, kms) } + passwordAuthRepoFn := func() (*password.Repository, error) { + return password.NewRepository(rw, rw, kms) + } + oidcAuthRepoFn := func() (*oidc.Repository, error) { + return oidc.NewRepository(ctx, rw, rw, kms) + } plg := host.TestPlugin(t, conn, "test") plgm := map[string]plgpb.HostPluginServiceClient{ @@ -2290,12 +2300,23 @@ func TestAuthorizeSession(t *testing.T) { return plugin.NewRepository(rw, rw, kms, sche, plgm) } + loginName := "foo@bar.com" + accountName := "passname" + userName := "username" + org, proj := iam.TestScopes(t, iamRepo) - at := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) - ctx = auth.NewVerifierContext(requests.NewRequestContext(ctx), + at := authtoken.TestAuthToken(t, + conn, + kms, + org.GetPublicId(), + authtoken.WithPasswordOptions(password.WithLoginName(loginName), password.WithName(accountName)), + authtoken.WithIamOptions(iam.WithName(userName))) + ctx = auth.NewVerifierContextWithAccounts(requests.NewRequestContext(ctx), iamRepoFn, atRepoFn, serversRepoFn, + passwordAuthRepoFn, + oidcAuthRepoFn, kms, &authpb.RequestInfo{ Token: at.GetToken(), @@ -2327,7 +2348,7 @@ func TestAuthorizeSession(t *testing.T) { plugin.TestRunSetSync(t, conn, kms, plgm) v := vault.NewTestVaultServer(t) - v.MountPKI(t) + v.MountPKI(t, vault.WithTestMountPath("pki/"+userName)) sec, tok := v.CreateToken(t, vault.WithPolicies([]string{"default", "boundary-controller", "pki"})) vaultStore := vault.TestCredentialStore(t, conn, wrapper, proj.GetPublicId(), v.Addr, tok, sec.Auth.Accessor) @@ -2340,13 +2361,13 @@ func TestAuthorizeSession(t *testing.T) { Attrs: &credlibpb.CredentialLibrary_VaultCredentialLibraryAttributes{ VaultCredentialLibraryAttributes: &credlibpb.VaultCredentialLibraryAttributes{ Path: &wrapperspb.StringValue{ - Value: path.Join("pki", "issue", "boundary"), + Value: path.Join("pki/{{ .User.Name}}", "issue", "boundary"), }, HttpMethod: &wrapperspb.StringValue{ Value: "POST", }, HttpRequestBody: &wrapperspb.StringValue{ - Value: `{"common_name":"boundary.com"}`, + Value: `{"common_name":"boundary.com", "alt_names": "{{.User.Name}},{{.Account.Name}},{{.Account.LoginName}},{{ truncateFrom .Account.LoginName "@" }}"}`, }, }, }, @@ -2474,6 +2495,16 @@ func TestAuthorizeSession(t *testing.T) { require.True(t, ok) assert.Truef(t, strings.HasPrefix(gotV.(string), v.(string)), "%q:%q doesn't have prefix %q", k, gotV, v) } + + b, _ := pem.Decode([]byte(dSec["certificate"].(string))) + require.NotNil(t, b) + cert, err := x509.ParseCertificate(b.Bytes) + require.NoError(t, err) + assert.Contains(t, cert.DNSNames, userName) + assert.Contains(t, cert.DNSNames, accountName) + assert.Contains(t, cert.DNSNames, strings.Split(loginName, "@")[0]) + assert.Contains(t, cert.EmailAddresses, loginName) + gotCred.Secret = nil got.AuthorizationToken, got.SessionId, got.CreatedTime = "", "", nil diff --git a/internal/daemon/controller/handlers/users/user_service.go b/internal/daemon/controller/handlers/users/user_service.go index ce8c71f283..8101e3f5ca 100644 --- a/internal/daemon/controller/handlers/users/user_service.go +++ b/internal/daemon/controller/handlers/users/user_service.go @@ -127,7 +127,7 @@ func (s Service) ListUsers(ctx context.Context, req *pbs.ListUsersRequest) (*pbs continue } - outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(authResults.UserId) + outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(*authResults.UserData.User.Id) outputOpts := make([]handlers.Option, 0, 3) outputOpts = append(outputOpts, handlers.WithOutputFields(&outputFields)) if outputFields.Has(globals.ScopeField) { diff --git a/internal/daemon/controller/interceptor.go b/internal/daemon/controller/interceptor.go index 840246512e..002980a607 100644 --- a/internal/daemon/controller/interceptor.go +++ b/internal/daemon/controller/interceptor.go @@ -48,6 +48,8 @@ func requestCtxInterceptor( iamRepoFn common.IamRepoFactory, authTokenRepoFn common.AuthTokenRepoFactory, serversRepoFn common.ServersRepoFactory, + passwordAuthRepoFn common.PasswordAuthRepoFactory, + oidcAuthRepoFn common.OidcAuthRepoFactory, kms *kms.Kms, ticket string, eventer *event.Eventer, @@ -105,7 +107,7 @@ func requestCtxInterceptor( return nil, errors.New(interceptorCtx, errors.Internal, op, "Invalid context (bad ticket)") } - interceptorCtx = auth.NewVerifierContext(interceptorCtx, iamRepoFn, authTokenRepoFn, serversRepoFn, kms, &requestInfo) + interceptorCtx = auth.NewVerifierContextWithAccounts(interceptorCtx, iamRepoFn, authTokenRepoFn, serversRepoFn, passwordAuthRepoFn, oidcAuthRepoFn, kms, &requestInfo) // Add general request information to the context. The information from // the auth verifier context is pretty specifically curated to diff --git a/internal/daemon/controller/interceptor_test.go b/internal/daemon/controller/interceptor_test.go index f0f64b5b6a..b51df75655 100644 --- a/internal/daemon/controller/interceptor_test.go +++ b/internal/daemon/controller/interceptor_test.go @@ -311,7 +311,7 @@ func Test_requestCtxInterceptor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) - interceptor, err := requestCtxInterceptor(factoryCtx, tt.iamRepoFn, tt.authTokenRepoFn, tt.serversRepoFn, tt.kms, tt.ticket, tt.eventer) + interceptor, err := requestCtxInterceptor(factoryCtx, tt.iamRepoFn, tt.authTokenRepoFn, tt.serversRepoFn, nil, nil, tt.kms, tt.ticket, tt.eventer) if tt.wantFactoryErr { require.Error(err) assert.Nil(interceptor) diff --git a/internal/daemon/controller/listeners.go b/internal/daemon/controller/listeners.go index 1799bf7e74..1895c7a631 100644 --- a/internal/daemon/controller/listeners.go +++ b/internal/daemon/controller/listeners.go @@ -35,7 +35,7 @@ func closeListener(_ context.Context, l net.Listener, _ any, _ int) error { func (c *Controller) startListeners() error { servers := make([]func(), 0, len(c.conf.Listeners)) - grpcServer, gwTicket, err := newGrpcServer(c.baseContext, c.IamRepoFn, c.AuthTokenRepoFn, c.ServersRepoFn, c.kms, c.conf.Eventer) + grpcServer, gwTicket, err := newGrpcServer(c.baseContext, c.IamRepoFn, c.AuthTokenRepoFn, c.ServersRepoFn, c.PasswordAuthRepoFn, c.OidcRepoFn, c.kms, c.conf.Eventer) if err != nil { return fmt.Errorf("failed to create new grpc server: %w", err) } diff --git a/internal/iam/repository_scope.go b/internal/iam/repository_scope.go index 6a31df27e5..95555b2c81 100644 --- a/internal/iam/repository_scope.go +++ b/internal/iam/repository_scope.go @@ -312,7 +312,7 @@ func (r *Repository) CreateScope(ctx context.Context, s *Scope, userId string, o } grants = append(grants, roleGrant) - roleGrant, err = NewRoleGrant(defaultRolePublicId, "id={{account.id}};actions=read,change-password") + roleGrant, err = NewRoleGrant(defaultRolePublicId, "id={{.Account.Id}};actions=read,change-password") if err != nil { return errors.Wrap(ctx, err, op, errors.WithMsg("unable to create in memory role grant")) } diff --git a/internal/perms/grants.go b/internal/perms/grants.go index bfcb5342be..e801c86bda 100644 --- a/internal/perms/grants.go +++ b/internal/perms/grants.go @@ -334,11 +334,11 @@ func Parse(scopeId, grantString string, opt ...Option) (Grant, error) { id := strings.TrimSuffix(strings.TrimPrefix(grant.id, "{{"), "}}") id = strings.TrimSpace(id) switch id { - case "user.id": + case "user.id", ".User.Id": if opts.withUserId != "" { grant.id = opts.withUserId } - case "account.id": + case "account.id", ".Account.Id": if opts.withAccountId != "" { grant.id = opts.withAccountId } diff --git a/internal/util/pointer.go b/internal/util/pointer.go new file mode 100644 index 0000000000..4231854fa6 --- /dev/null +++ b/internal/util/pointer.go @@ -0,0 +1,8 @@ +package util + +// Pointer is just a generic function to return a pointer of whatever type is +// given +func Pointer[T any](input T) *T { + ret := input + return &ret +} diff --git a/internal/util/template/doc.go b/internal/util/template/doc.go new file mode 100644 index 0000000000..f6ded4549b --- /dev/null +++ b/internal/util/template/doc.go @@ -0,0 +1,8 @@ +package template + +/* +The template package provides some common functions and methods to enable +templating Boundary data into various places. This will not necessarily be a +one-stop-shop for all templating needs, but using this package and its Data +struct can help ensure consistency within templates. +*/ diff --git a/internal/util/template/funcs.go b/internal/util/template/funcs.go new file mode 100644 index 0000000000..f2b1ae932e --- /dev/null +++ b/internal/util/template/funcs.go @@ -0,0 +1,11 @@ +package template + +import "strings" + +// truncateFrom will truncate a string after the first encounter of sep; sep is +// elided. This is a passthrough to strings.Cut with only the first return +// value. +func truncateFrom(str, sep string) string { + before, _, _ := strings.Cut(str, sep) + return before +} diff --git a/internal/util/template/generate.go b/internal/util/template/generate.go new file mode 100644 index 0000000000..b833426d3c --- /dev/null +++ b/internal/util/template/generate.go @@ -0,0 +1,69 @@ +package template + +import ( + "context" + "strings" + "text/template" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// Parsed contains information about a template parsed via New. Technically +// `raw` and `funcMap` are not required to be cached here as they are part of +// the `template.Template` object but it is useful for tests. +type Parsed struct { + raw string + tmpl *template.Template + funcMap template.FuncMap +} + +// New creates a Parsed struct. It requires the raw string. +func New(ctx context.Context, raw string) (*Parsed, error) { + const op = "util.template.New" + + if raw == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "empty raw template") + } + + ret := &Parsed{ + raw: raw, + funcMap: map[string]interface{}{ + "truncateFrom": truncateFrom, + }, + } + + tmpl, err := template.New("template"). + Funcs(ret.funcMap). + Parse(ret.raw) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error parsing template")) + } + ret.tmpl = tmpl + + return ret, nil +} + +// Generate based on the provided template +func (p *Parsed) Generate(ctx context.Context, data interface{}) (string, error) { + const op = "util.template.(Parsed).Generate" + + switch { + case p.tmpl == nil: + return "", errors.New(ctx, errors.InvalidParameter, op, "parsed template not initialized") + case util.IsNil(data): + return "", errors.New(ctx, errors.InvalidParameter, op, "input data is nil") + } + + str := &strings.Builder{} + if err := p.tmpl.Execute(str, data); err != nil { + return "", errors.Wrap(ctx, err, op, errors.WithMsg("error executing template")) + } + + out := str.String() + if strings.Contains(out, "") { + return "", errors.New(ctx, errors.InvalidParameter, op, "template execution resulted in nil value") + } + + return out, nil +} diff --git a/internal/util/template/generate_test.go b/internal/util/template/generate_test.go new file mode 100644 index 0000000000..23a0f854f8 --- /dev/null +++ b/internal/util/template/generate_test.go @@ -0,0 +1,131 @@ +package template + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/boundary/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// NOTE: Although this does a basic test of template generation, other tests +// that are closer to where this library is used can and should also be enhanced +// over time. For instance, TestAuthorizeSession from target_service_test.go has +// been enhanced to use this library to check output against Vault. + +func TestErrors(t *testing.T) { + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + + // No template + parsed, err := New(ctx, "") + assert.Error(err) + assert.Nil(parsed) + + // Bad template + parsed, err = New(ctx, "{{ foobar") + assert.Error(err) + assert.Nil(parsed) + + // Good template + ts := "Foobar:{{ .User.Name }}" + parsed, err = New(ctx, ts) + assert.NoError(err) + require.NotNil(parsed) + assert.Equal(ts, parsed.raw) + assert.NotNil(parsed.tmpl) + assert.Len(parsed.funcMap, 1) + + // Test out errors on the parsed value + + // Nil template + oldTmpl := parsed.tmpl + parsed.tmpl = nil + out, err := parsed.Generate(ctx, Data{}) + assert.Error(err) + assert.Empty(out) + parsed.tmpl = oldTmpl + + // Nil + out, err = parsed.Generate(ctx, nil) + assert.Error(err) + assert.Empty(out) + + // Nil pointer + var nilData *Data + out, err = parsed.Generate(ctx, nilData) + assert.Error(err) + assert.Empty(out) + + // Nil internal data + _, err = parsed.Generate(ctx, Data{}) + assert.Error(err) + + // Good data + out, err = parsed.Generate(ctx, Data{User: User{Name: util.Pointer("name")}}) + require.NoError(err) + assert.Equal("Foobar:name", out) +} + +func TestGenerate(t *testing.T) { + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + data := Data{ + User: User{ + Id: util.Pointer("userId"), + Name: util.Pointer("userName"), + FullName: util.Pointer("userFullName"), + Email: util.Pointer("user@email.com"), + }, + Account: Account{ + Id: util.Pointer("accountId"), + Name: util.Pointer("accountName"), + LoginName: util.Pointer("accountLoginName"), + Subject: util.Pointer("accountSubject"), + Email: util.Pointer("account@email.com"), + }, + } + raw := strings.TrimSpace(` +{{ .User.Id }} +{{ .User.Name }} +{{ .User.FullName }} +{{ .User.Email }} +{{ truncateFrom .User.Email "@" }} +{{ .Account.Id }} +{{ .Account.Name }} +{{ .Account.LoginName }} +{{ .Account.Subject }} +{{ .Account.Email }} +{{ truncateFrom .Account.Email "@" }} +`) + + parsed, err := New(ctx, raw) + require.NoError(err) + require.NotNil(parsed) + + // Ensure we error with required data + _, err = parsed.Generate(ctx, Data{}) + require.Error(err) + + // Do again with non-empty data + out, err := parsed.Generate(ctx, data) + require.NoError(err) + + exp := strings.TrimSpace(` +userId +userName +userFullName +user@email.com +user +accountId +accountName +accountLoginName +accountSubject +account@email.com +account +`) + + assert.Equal(exp, out) +} diff --git a/internal/util/template/types.go b/internal/util/template/types.go new file mode 100644 index 0000000000..6523ddf282 --- /dev/null +++ b/internal/util/template/types.go @@ -0,0 +1,33 @@ +package template + +// Data is the top-level struct containing the various domains of templated +// data. The Generate function can take in any data but this provides a shared +// structure for general use. +type Data struct { + User User + Account Account +} + +// User contains user information. FullName and Email are not always populated +// or may be different than the values in the Account struct; these are set on +// the user by an account from the primary auth method in a scope. It is +// possible for a user to not have an account from that auth method (in which +// case it will not be populated), or for the token they have used for the +// request to be from a different auth method, in which case it may not match +// what's in the Account struct. +type User struct { + Id *string + Name *string + FullName *string + Email *string +} + +// Account contains account information. Not all fields will always be +// populated; it depends on the type of account. +type Account struct { + Id *string + Name *string + LoginName *string + Subject *string + Email *string +} diff --git a/website/content/docs/concepts/security/permissions/permission-grant-formats.mdx b/website/content/docs/concepts/security/permissions/permission-grant-formats.mdx index 22dfa83f61..9383017925 100644 --- a/website/content/docs/concepts/security/permissions/permission-grant-formats.mdx +++ b/website/content/docs/concepts/security/permissions/permission-grant-formats.mdx @@ -112,11 +112,19 @@ Such a grant is essentially a full administrator grant for a scope. A few template possibilities exist, which will at grant evaluation time substitute the given value into the ID field of the grant string: -- `{{account.id}}`: The substituted value is the account ID associated with the +- `{{.Account.Id}}`: The substituted value is the account ID associated with the token used to perform the action. As an example, - `id={{account.id}};actions=read,change-password"` is one of Boundary's default - grants to allow users that have authenticated with the Password auth method to - change their own password. - -- `{{user.id}}`: The substituted value is the user ID associated with the token + `id={{.Account.Id}};actions=read,change-password"` is one of Boundary's + default grants to allow users that have authenticated with the Password auth + method to change their own password. + - **NOTE**: Prior to Boundary 0.11.1, `{{account.id}}` must be used instead. + Boundary 0.11.1+ changes this for consistency with other places within + Boundary that are gaining templating support, but supports both formats for + backwards compatibility. + +- `{{.User.Id}}`: The substituted value is the user ID associated with the token used to perform the action. + - **NOTE**: Prior to Boundary 0.11.1, `{{user.id}}` must be used instead. + Boundary 0.11.1+ changes this for consistency with other places within + Boundary that are gaining templating support, but supports both formats for + backwards compatibility. diff --git a/website/content/docs/oss/installing/no-gen-resources.mdx b/website/content/docs/oss/installing/no-gen-resources.mdx index de2ed40e5b..51af99019c 100644 --- a/website/content/docs/oss/installing/no-gen-resources.mdx +++ b/website/content/docs/oss/installing/no-gen-resources.mdx @@ -323,7 +323,7 @@ $ boundary roles add-grants -id \ -recovery-config /tmp/recovery.hcl \ -grant 'id=*;type=auth-method;actions=list,authenticate' \ -grant 'id=*;type=scope;actions=list,no-op' \ - -grant 'id={{account.id}};actions=read,change-password' + -grant 'id={{.Account.Id}};actions=read,change-password' $ boundary roles add-principals -id \ -recovery-config /tmp/recovery.hcl \ @@ -340,7 +340,7 @@ resource "boundary_role" "global_anon_listing" { grant_strings = [ "id=*;type=auth-method;actions=list,authenticate", "id=*;type=scope;actions=list,no-op", - "id={{account.id}};actions=read,change-password" + "id={{.Account.Id}};actions=read,change-password" ] principal_ids = ["u_anon"] } @@ -367,7 +367,7 @@ $ boundary roles add-grants -id \ -recovery-config /tmp/recovery.hcl \ -grant 'id=*;type=auth-method;actions=list,authenticate' \ -grant 'type=scope;actions=list' \ - -grant 'id={{account.id}};actions=read,change-password' + -grant 'id={{.Account.Id}};actions=read,change-password' $ boundary roles add-principals -id \ -recovery-config /tmp/recovery.hcl \ @@ -384,7 +384,7 @@ resource "boundary_role" "org_anon_listing" { grant_strings = [ "id=*;type=auth-method;actions=list,authenticate", "type=scope;actions=list", - "id={{account.id}};actions=read,change-password" + "id={{.Account.Id}};actions=read,change-password" ] principal_ids = ["u_anon"] }