You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
consul/internal/resource/registry.go

216 lines
6.7 KiB

// Copyright (c) HashiCorp, Inc.
[COMPLIANCE] License changes (#18443) * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at <Blog URL>, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * add missing license headers * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
1 year ago
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"errors"
"fmt"
"regexp"
"strings"
"sync"
"google.golang.org/protobuf/proto"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/internal/storage"
"github.com/hashicorp/consul/proto-public/pbresource"
)
var (
groupRegexp = regexp.MustCompile(`^[a-z][a-z\d_]+$`)
groupVersionRegexp = regexp.MustCompile(`^v([a-z\d]+)?\d$`)
kindRegexp = regexp.MustCompile(`^[A-Z][A-Za-z\d]+$`)
// Track resource types that are allowed to have an undefined scope. These are usually
// non-customer facing or internal types.
undefinedScopeAllowed = map[string]bool{
storage.UnversionedTypeFrom(TypeV1Tombstone).String(): true,
}
)
func isUndefinedScopeAllowed(t *pbresource.Type) bool {
return undefinedScopeAllowed[storage.UnversionedTypeFrom(t).String()]
}
type Registry interface {
// Register the given resource type and its hooks.
Register(reg Registration)
// Resolve the given resource type and its hooks.
Resolve(typ *pbresource.Type) (reg Registration, ok bool)
Types() []Registration
}
type Registration struct {
// Type is the GVK of the resource type.
Type *pbresource.Type
// Proto is the resource's protobuf message type.
Proto proto.Message
// ACLs are hooks called to perform authorization on RPCs.
// The hooks can assume that Validate has been called.
ACLs *ACLHooks
// Validate is called to structurally validate the resource (e.g.
// check for required fields). Validate can assume that Mutate
// has been called.
Validate func(*pbresource.Resource) error
// Mutate is called to fill out any autogenerated fields (e.g. UUIDs) or
// apply defaults before validation. Mutate can assume that
// Resource.ID is populated and has non-empty tenancy fields. This does
// not mean those tenancy fields actually exist.
Mutate func(*pbresource.Resource) error
// Scope describes the tenancy scope of a resource.
Scope Scope
}
var ErrNeedData = errors.New("authorization check requires resource data")
type ACLHooks struct {
// Read is used to authorize Read RPCs and to filter results in List
// RPCs.
//
// It can be called an ID and possibly a Resource. The check will first
// attempt to use the ID and if the hook returns ErrNeedData, then the
// check will be deferred until the data is fetched from the storage layer.
//
// If it is omitted, `operator:read` permission is assumed.
Read func(acl.Authorizer, *acl.AuthorizerContext, *pbresource.ID, *pbresource.Resource) error
// Write is used to authorize Write and Delete RPCs.
//
// If it is omitted, `operator:write` permission is assumed.
Write func(acl.Authorizer, *acl.AuthorizerContext, *pbresource.Resource) error
// List is used to authorize List RPCs.
//
// If it is omitted, we only filter the results using Read.
List func(acl.Authorizer, *acl.AuthorizerContext) error
}
// Resource type registry
type TypeRegistry struct {
// registrations keyed by GVK
registrations map[string]Registration
lock sync.RWMutex
}
func NewRegistry() Registry {
registry := &TypeRegistry{registrations: make(map[string]Registration)}
// Tombstone is an implicitly registered type since it is used to implement
// the cascading deletion of resources. ACLs end up being defaulted to
// operator:<read,write>. It is useful to note that tombstone creation
// does not get routed through the resource service and bypasses ACLs
// as part of the Delete endpoint.
registry.Register(Registration{
Type: TypeV1Tombstone,
Proto: &pbresource.Tombstone{},
})
return registry
}
func (r *TypeRegistry) Register(registration Registration) {
typ := registration.Type
if typ.Group == "" || typ.GroupVersion == "" || typ.Kind == "" {
panic("type field(s) cannot be empty")
}
switch {
case !groupRegexp.MatchString(typ.Group):
panic(fmt.Sprintf("Type.Group must be in snake_case. Got: %q", typ.Group))
case !groupVersionRegexp.MatchString(typ.GroupVersion):
panic(fmt.Sprintf("Type.GroupVersion must be lowercase, start with `v`, and end with a number (e.g. `v2` or `v2beta1`). Got: %q", typ.Group))
case !kindRegexp.MatchString(typ.Kind):
panic(fmt.Sprintf("Type.Kind must be in PascalCase. Got: %q", typ.Kind))
}
if registration.Proto == nil {
panic("Proto field is required.")
}
if registration.Scope == ScopeUndefined && !isUndefinedScopeAllowed(typ) {
panic(fmt.Sprintf("scope required for %s. Got: %q", typ, registration.Scope))
}
r.lock.Lock()
defer r.lock.Unlock()
key := ToGVK(registration.Type)
if _, ok := r.registrations[key]; ok {
panic(fmt.Sprintf("resource type %s already registered", key))
}
// set default acl hooks for those not provided
if registration.ACLs == nil {
registration.ACLs = &ACLHooks{}
}
if registration.ACLs.Read == nil {
registration.ACLs.Read = func(authz acl.Authorizer, authzContext *acl.AuthorizerContext, id *pbresource.ID, _ *pbresource.Resource) error {
return authz.ToAllowAuthorizer().OperatorReadAllowed(authzContext)
}
}
if registration.ACLs.Write == nil {
registration.ACLs.Write = func(authz acl.Authorizer, authzContext *acl.AuthorizerContext, id *pbresource.Resource) error {
return authz.ToAllowAuthorizer().OperatorWriteAllowed(authzContext)
}
}
if registration.ACLs.List == nil {
registration.ACLs.List = func(authz acl.Authorizer, authzContext *acl.AuthorizerContext) error {
return authz.ToAllowAuthorizer().OperatorReadAllowed(&acl.AuthorizerContext{})
}
}
// default validation to a no-op
if registration.Validate == nil {
registration.Validate = func(resource *pbresource.Resource) error { return nil }
}
// default mutate to a no-op
if registration.Mutate == nil {
registration.Mutate = func(resource *pbresource.Resource) error { return nil }
}
r.registrations[key] = registration
}
func (r *TypeRegistry) Resolve(typ *pbresource.Type) (reg Registration, ok bool) {
r.lock.RLock()
defer r.lock.RUnlock()
if registration, ok := r.registrations[ToGVK(typ)]; ok {
return registration, true
}
return Registration{}, false
}
func (r *TypeRegistry) Types() []Registration {
r.lock.RLock()
defer r.lock.RUnlock()
types := make([]Registration, 0, len(r.registrations))
for _, v := range r.registrations {
types = append(types, v)
}
return types
}
func ToGVK(resourceType *pbresource.Type) string {
return fmt.Sprintf("%s.%s.%s", resourceType.Group, resourceType.GroupVersion, resourceType.Kind)
}
func ParseGVK(gvk string) (*pbresource.Type, error) {
parts := strings.Split(gvk, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("GVK string must be in the form <Group>.<GroupVersion>.<Kind>, got: %s", gvk)
}
return &pbresource.Type{
Group: parts[0],
GroupVersion: parts[1],
Kind: parts[2],
}, nil
}