304 lines
9.2 KiB
Go
304 lines
9.2 KiB
Go
package auth
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/hashicorp/go-hclog"
|
||
|
|
"github.com/hashicorp/watchtower/internal/perms"
|
||
|
|
"github.com/hashicorp/watchtower/internal/servers/controller/common"
|
||
|
|
"github.com/hashicorp/watchtower/internal/types/action"
|
||
|
|
"github.com/hashicorp/watchtower/internal/types/resource"
|
||
|
|
"github.com/hashicorp/watchtower/internal/types/scope"
|
||
|
|
"github.com/kr/pretty"
|
||
|
|
)
|
||
|
|
|
||
|
|
type key int
|
||
|
|
|
||
|
|
var verifierKey key
|
||
|
|
|
||
|
|
// RequestInfo contains request parameters necessary for checking authn/authz
|
||
|
|
type RequestInfo struct {
|
||
|
|
Path string
|
||
|
|
Method string
|
||
|
|
PublicId string
|
||
|
|
Token string
|
||
|
|
DisableAuthzFailures bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type verifier struct {
|
||
|
|
logger hclog.Logger
|
||
|
|
iamRepoFn common.IamRepoFactory
|
||
|
|
authTokenRepoFn common.AuthTokenRepoFactory
|
||
|
|
requestInfo RequestInfo
|
||
|
|
res *perms.Resource
|
||
|
|
act action.Type
|
||
|
|
ctx context.Context
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewVerifierContext creates a context that carries a verifier object from the
|
||
|
|
// HTTP handlers to the gRPC service handlers. It should only be created in the
|
||
|
|
// HTTP handler and should exist for every request that reaches the service
|
||
|
|
// handlers.
|
||
|
|
func NewVerifierContext(ctx context.Context,
|
||
|
|
logger hclog.Logger,
|
||
|
|
iamRepoFn common.IamRepoFactory,
|
||
|
|
authTokenRepoFn common.AuthTokenRepoFactory,
|
||
|
|
requestInfo RequestInfo) context.Context {
|
||
|
|
return context.WithValue(ctx, verifierKey, &verifier{
|
||
|
|
logger: logger,
|
||
|
|
iamRepoFn: iamRepoFn,
|
||
|
|
authTokenRepoFn: authTokenRepoFn,
|
||
|
|
requestInfo: requestInfo,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify takes in a context that has expected parameters as values and runs an
|
||
|
|
// authn/authz check. It returns a user ID, the scope ID for the request (which
|
||
|
|
// may come from the URL and may come from the token) and whether or not to
|
||
|
|
// proceed, e.g. whether the authn/authz check resulted in failure. If an error
|
||
|
|
// occurs it's logged to the system log.
|
||
|
|
func Verify(ctx context.Context) (userId string, scopeId string, valid bool) {
|
||
|
|
v, ok := ctx.Value(verifierKey).(*verifier)
|
||
|
|
if !ok {
|
||
|
|
// We don't have a logger yet and this should never happen in any
|
||
|
|
// context we won't catch in tests
|
||
|
|
panic("no verifier information found in context")
|
||
|
|
}
|
||
|
|
v.ctx = ctx
|
||
|
|
if err := v.parseAuthParams(); err != nil {
|
||
|
|
v.logger.Trace("error reading auth parameters from URL", "url", v.requestInfo.Path, "method", v.requestInfo.Method, "error", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if v.res == nil {
|
||
|
|
v.logger.Trace("got nil resource information after decorating auth parameters")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
authResults, userId, scopeId, err := v.performAuthCheck()
|
||
|
|
if err != nil {
|
||
|
|
v.logger.Error("error performing authn/authz check", "error", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !authResults.Allowed {
|
||
|
|
// TODO: Decide whether to remove this
|
||
|
|
if v.requestInfo.DisableAuthzFailures {
|
||
|
|
v.logger.Info("failed authz info for request", "resource", pretty.Sprint(v.res), "user_id", userId, "action", v.act.String())
|
||
|
|
} else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
valid = true
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
func (v *verifier) parseAuthParams() error {
|
||
|
|
// Remove trailing and leading slashes
|
||
|
|
trimmedPath := strings.Trim(v.requestInfo.Path, "/")
|
||
|
|
splitPath := strings.Split(strings.TrimPrefix(trimmedPath, "v1"), "/")
|
||
|
|
splitLen := len(splitPath)
|
||
|
|
if splitLen == 0 {
|
||
|
|
return fmt.Errorf("parse auth params: invalid path")
|
||
|
|
}
|
||
|
|
|
||
|
|
var act action.Type
|
||
|
|
var typStr string
|
||
|
|
scp := scope.Global
|
||
|
|
res := &perms.Resource{
|
||
|
|
ScopeId: scope.Global.String(),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle non-custom types. We'll deal with custom types, including list,
|
||
|
|
// after parsing the path.
|
||
|
|
switch v.requestInfo.Method {
|
||
|
|
case "GET":
|
||
|
|
act = action.Read
|
||
|
|
case "POST":
|
||
|
|
act = action.Create
|
||
|
|
case "PATCH":
|
||
|
|
act = action.Update
|
||
|
|
case "DELETE":
|
||
|
|
act = action.Delete
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("parse auth params: unknown method %q", v.requestInfo.Method)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Look for a custom action
|
||
|
|
colonSplit := strings.Split(splitPath[splitLen-1], ":")
|
||
|
|
switch len(colonSplit) {
|
||
|
|
case 1:
|
||
|
|
// No custom action specified
|
||
|
|
case 2:
|
||
|
|
actStr := colonSplit[len(colonSplit)-1]
|
||
|
|
act = action.Map[actStr]
|
||
|
|
if act == action.Unknown || act == action.All {
|
||
|
|
return fmt.Errorf("parse auth params: unknown action %q", actStr)
|
||
|
|
}
|
||
|
|
// Keep going with the logic without the custom action
|
||
|
|
splitPath[splitLen-1] = colonSplit[0]
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("parse auth params: unexpected number of colons in last segment %q", colonSplit[len(colonSplit)-1])
|
||
|
|
}
|
||
|
|
|
||
|
|
// Walk backwards. As we walk backwards we look for scopes and figure out if
|
||
|
|
// we're operating on a resource or a collection. We also populate the pin.
|
||
|
|
// The rules for the pin are as follows:
|
||
|
|
//
|
||
|
|
// * If the last segment is a collection, the pin is the immediately
|
||
|
|
// preceding ID
|
||
|
|
//
|
||
|
|
// * If the last segment is an ID, the pin is the immediately preceding ID
|
||
|
|
// not including the last segment
|
||
|
|
//
|
||
|
|
// * If at the end of the logic the pin is the id of a scope ("global",
|
||
|
|
// "o_...", "p_...") then there is no pin. The scopes are already enclosing
|
||
|
|
// so a pin is redundant.
|
||
|
|
nextIdIsPin := true
|
||
|
|
for i := splitLen - 1; i >= 0; i-- {
|
||
|
|
segment := splitPath[i]
|
||
|
|
|
||
|
|
// Collections don't contain underscores; every resource ID does.
|
||
|
|
segmentIsCollection := !strings.Contains(segment, "_")
|
||
|
|
|
||
|
|
if !segmentIsCollection && i != splitLen-1 && nextIdIsPin {
|
||
|
|
res.Pin = segment
|
||
|
|
nextIdIsPin = false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update the scope. Set it to org only if it's at global (that way we
|
||
|
|
// don't override project with org). We have to check if it's one less
|
||
|
|
// than the length of the split because operating on the id of a scope
|
||
|
|
// is actually in the enclosing scope (since you're in the parent scope
|
||
|
|
// operating on a child scope).
|
||
|
|
switch segment {
|
||
|
|
case "projects":
|
||
|
|
if i < splitLen-2 {
|
||
|
|
scp = scope.Project
|
||
|
|
res.ScopeId = splitPath[i+1]
|
||
|
|
}
|
||
|
|
case "orgs":
|
||
|
|
if scp == scope.Global {
|
||
|
|
if i < splitLen-2 {
|
||
|
|
scp = scope.Org
|
||
|
|
res.ScopeId = splitPath[i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if segment == "" {
|
||
|
|
// This could be the case if we have an action like
|
||
|
|
// /orgs/o_1234/projects/p_1234/:set-defaults to act on the project
|
||
|
|
// itself but within its own scope
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if typStr == "" {
|
||
|
|
// The resource check takes place inside the type check because if
|
||
|
|
// we've identified the type we have either already identified the
|
||
|
|
// right-most resource ID or we're operating on a collection, so
|
||
|
|
// this prevents us from finding a different ID earlier in the path.
|
||
|
|
//
|
||
|
|
// We continue on with the enclosing loop anyways though to ensure
|
||
|
|
// we find the right scope.
|
||
|
|
if res.Id == "" && !segmentIsCollection {
|
||
|
|
res.Id = segment
|
||
|
|
} else {
|
||
|
|
// Every collection is the plural of the resource type so drop
|
||
|
|
// the last 's'
|
||
|
|
if !strings.HasSuffix(segment, "s") {
|
||
|
|
return fmt.Errorf("parse auth params: invalid collection syntax for %q", segment)
|
||
|
|
}
|
||
|
|
typStr = strings.TrimSuffix(segment, "s")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if typStr != "" {
|
||
|
|
res.Type = resource.Map[typStr]
|
||
|
|
if res.Type == resource.Unknown {
|
||
|
|
return fmt.Errorf("parse auth params: unknown resource type %q", typStr)
|
||
|
|
}
|
||
|
|
} else if res.Id == "" {
|
||
|
|
return errors.New("parse auth params: id and type both not found")
|
||
|
|
}
|
||
|
|
|
||
|
|
// If we're operating on a collection (that is, the ID is blank) and it's a
|
||
|
|
// GET, it's actually a list
|
||
|
|
if res.Id == "" && act == action.Read {
|
||
|
|
act = action.List
|
||
|
|
}
|
||
|
|
|
||
|
|
// If the pin ended up being a scope, nil it out
|
||
|
|
if res.Pin != "" {
|
||
|
|
if res.Pin == "global" ||
|
||
|
|
strings.HasPrefix(res.Pin, "o_") ||
|
||
|
|
strings.HasPrefix(res.Pin, "p_") {
|
||
|
|
res.Pin = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
v.res = res
|
||
|
|
v.act = act
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (v verifier) performAuthCheck() (aclResults *perms.ACLResults, userId, scopeId string, retErr error) {
|
||
|
|
// Ensure we return an error by default if we forget to set this somewhere
|
||
|
|
retErr = errors.New("unknown")
|
||
|
|
userId = "u_anon"
|
||
|
|
scopeId = v.res.ScopeId
|
||
|
|
|
||
|
|
// Validate the token and fetch the corresponding user ID
|
||
|
|
tokenRepo, err := v.authTokenRepoFn()
|
||
|
|
if err != nil {
|
||
|
|
retErr = fmt.Errorf("perform auth check: failed to get authtoken repo: %w", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
at, err := tokenRepo.ValidateToken(v.ctx, v.requestInfo.PublicId, v.requestInfo.Token)
|
||
|
|
if err != nil {
|
||
|
|
retErr = fmt.Errorf("perform auth check: failed to validate token: %w", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if at != nil {
|
||
|
|
userId = at.GetIamUserId()
|
||
|
|
}
|
||
|
|
|
||
|
|
var parsedGrants []perms.Grant
|
||
|
|
var grantPairs []perms.GrantPair
|
||
|
|
|
||
|
|
// Fetch and parse grants for this user ID (which may include grants for
|
||
|
|
// u_anon and u_auth)
|
||
|
|
iamRepo, err := v.iamRepoFn()
|
||
|
|
if err != nil {
|
||
|
|
retErr = fmt.Errorf("perform auth check: failed to get iam repo: %w", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
grantPairs, err = iamRepo.GrantsForUser(v.ctx, userId)
|
||
|
|
if err != nil {
|
||
|
|
retErr = fmt.Errorf("perform auth check: failed to query for user grants: %w", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
parsedGrants = make([]perms.Grant, 0, len(grantPairs))
|
||
|
|
for _, pair := range grantPairs {
|
||
|
|
parsed, err := perms.Parse(pair.ScopeId, userId, pair.Grant)
|
||
|
|
if err != nil {
|
||
|
|
retErr = fmt.Errorf("perform auth check: failed to parse grant %#v: %w", pair.Grant, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
parsedGrants = append(parsedGrants, parsed)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO: When we migrate to scopes, the resource scope ID for this check and
|
||
|
|
// in the return value needs to be adjusted to the token scope ID for
|
||
|
|
// actions within the scopes collection
|
||
|
|
acl := perms.NewACL(parsedGrants...)
|
||
|
|
allowed := acl.Allowed(*v.res, v.act)
|
||
|
|
|
||
|
|
aclResults = &allowed
|
||
|
|
retErr = nil
|
||
|
|
return
|
||
|
|
}
|