Add initial templating support to Vault credential libraries (#2569) (#2575)

Add initial templating support to Vault credential libraries

This adds support for templating various account and user values into
Vault dynamic credential library POST bodies and GET/POST paths.

It also unifies how parameters are used in grants, with backwards
compatibility.
pull/2576/head
Jeff Mitchell 4 years ago committed by GitHub
parent b5ef8a516b
commit cede1aec93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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)

@ -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.

@ -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
}
}

@ -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)
})
}

@ -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))

@ -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
}

@ -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.

@ -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
}

@ -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)
}

@ -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

@ -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) {

@ -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

@ -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)

@ -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)
}

@ -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"))
}

@ -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
}

@ -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
}

@ -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.
*/

@ -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
}

@ -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, "<nil>") {
return "", errors.New(ctx, errors.InvalidParameter, op, "template execution resulted in nil value")
}
return out, nil
}

@ -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)
}

@ -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
}

@ -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.

@ -323,7 +323,7 @@ $ boundary roles add-grants -id <global_anon_listing_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 <global_anon_listing_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 <org_anon_listing_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 <org_anon_listing_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"]
}

Loading…
Cancel
Save