* Move verification to a shared package The verifier is constructed at HTTP request time and added to the context. It can then be called from appropriate handlers with standardized error handling after all the routing. I intentionally did not add to the handlers yet as I am a good distance into the scope updates and didn't want to cause even more merge conflicts. So at the moment authz is again disabled, but once the scope updates are done, this will be integrated back in.
305 lines
9.2 KiB
Go
305 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
|
|
}
|