Documentation
¶
Overview ¶
Package grpc enforces authorization on gRPC methods.
An Enforcer pairs a frozen Requirements table with a authorization.GrantsExtractor and produces both interceptors:
reqs, err := authzgrpc.NewRequirements().
RequireAll(mealplanning.MethodPermissions()).
RequireAll(identity.MethodPermissions()).
Public(healthpb.Health_Check_FullMethodName).
Build()
// ...
enforcer, err := authzgrpc.NewEnforcer(reqs, extractGrants,
authzgrpc.WithLogger(logger),
authzgrpc.WithMetricsProvider(metricsProvider),
)
// ...
server, err := grpcserver.NewGRPCServer(cfg,
[]grpc.UnaryServerInterceptor{authInterceptor, enforcer.UnaryServerInterceptor()},
[]grpc.StreamServerInterceptor{authStreamInterceptor, enforcer.StreamServerInterceptor()},
registrations,
grpcserver.WithLogger(logger), grpcserver.WithTracerProvider(tracerProvider),
)
One decision, two interceptors ¶
Both interceptors are three lines around a single unexported check. That is the main thing this package is for: hand-written unary and stream interceptors enforcing "the same" rule drift apart, and they drift silently, because nothing compares them. Here there is one rule and one place to change it, and the tests drive the same table through both entry points.
Ordering ¶
Install this after authentication and inside the error-encoding interceptor:
- Authentication must run first, or the extractor finds nothing and every call is denied. That case is counted separately (authorization_grpc_missing_grants) and logged at error level, because it is a wiring bug rather than an overreaching caller.
- errors/grpc's encoding interceptor, when used, should wrap this one so the denial's error chain reaches the client and errors.Is works there too. The denial carries its own GRPCStatus, so the wire code is correct either way.
Fail closed ¶
A method absent from the table is denied. Public is how a method opts out, so "needs no authorization" is a declaration rather than an omission, and forgetting to register a method produces a denial rather than an opening.
Build refuses a method required with zero permissions. Vacuous truth means an empty requirement would authorize everyone while reading like a restriction, and that gap is exactly where an authorization hole hides. Build also refuses a method declared twice, which is what happens when two service packages contribute overlapping tables and one silently wins.
Requirements is immutable once built, so neither interceptor takes a lock. A mutable table guarded by a mutex costs an acquisition on every RPC to protect a map that is never written after startup.
Rollout ¶
WithAuditOnly evaluates and records every decision but denies nothing. Deploy with it, watch authorization_grpc_denials and authorization_grpc_undeclared_methods settle to zero, then remove it. Without that step, enabling enforcement over a large hand-written table is a bet that every entry is right on the first try.
Watching it ¶
Every instrument carries a method attribute, because one Enforcer serves every method and a single mis-declared one is invisible in the total: authorization_grpc_checks, authorization_grpc_denials, authorization_grpc_undeclared_methods, and authorization_grpc_missing_grants.
The transport is in the name, so these stay distinguishable from the HTTP middleware's authorization_http_* counters when a service installs both — an un-suffixed name would read as a service-wide total that it is not.
Alert on undeclared_methods and missing_grants — both mean the wiring is wrong, not that a caller misbehaved.
Decisions are attached to the ambient RPC span rather than a child span. An authorization check is a few map lookups; a span per RPC to describe it would double the trace volume of every service that installs this.
Index ¶
- Variables
- type Enforcer
- type Option
- type Requirements
- type RequirementsBuilder
- func (b *RequirementsBuilder) Build() (*Requirements, error)
- func (b *RequirementsBuilder) Public(fullMethod string) *RequirementsBuilder
- func (b *RequirementsBuilder) Require(fullMethod string, perms ...authorization.Permission) *RequirementsBuilder
- func (b *RequirementsBuilder) RequireAll(m map[string][]authorization.Permission) *RequirementsBuilder
Constants ¶
This section is empty.
Variables ¶
var ( // ErrEmptyMethod indicates a requirement was declared for an empty method name. ErrEmptyMethod = errors.New("empty method name") // ErrDuplicateMethod indicates the same method was declared more than once. ErrDuplicateMethod = errors.New("method declared more than once") // ErrNoPermissionsRequired indicates Require was called with no permissions, // which would authorize every caller for that method. ErrNoPermissionsRequired = errors.New("method required with no permissions") // ErrEmptyPermission indicates a requirement listed an empty permission. ErrEmptyPermission = errors.New("empty permission required") )
Functions ¶
This section is empty.
Types ¶
type Enforcer ¶
type Enforcer struct {
// contains filtered or unexported fields
}
Enforcer authorizes RPCs against a Requirements table.
One Enforcer serves every method, which is why every measurement it records carries a method attribute: a single mis-declared method is invisible in the total.
func NewEnforcer ¶
func NewEnforcer(reqs *Requirements, extract authorization.GrantsExtractor, opts ...Option) (*Enforcer, error)
NewEnforcer builds an Enforcer over a frozen Requirements table.
func (*Enforcer) StreamServerInterceptor ¶
func (e *Enforcer) StreamServerInterceptor() grpc.StreamServerInterceptor
StreamServerInterceptor authorizes streaming RPCs.
Streams are authorized once, when the stream opens. Nothing re-checks mid-stream, so a long-lived stream outlives a revocation of the authority that opened it — the same property the unary path has for the duration of a call, just over a longer window.
func (*Enforcer) UnaryServerInterceptor ¶
func (e *Enforcer) UnaryServerInterceptor() grpc.UnaryServerInterceptor
UnaryServerInterceptor authorizes unary RPCs.
type Option ¶
type Option func(*Enforcer)
Option configures an Enforcer.
func WithAuditOnly ¶
func WithAuditOnly() Option
WithAuditOnly evaluates and records every decision but denies nothing.
This is the rollout tool. Turning enforcement on across a service that has never had it is otherwise a coin flip: the table is large, hand-written, and a single missing entry becomes an outage on deploy. Run audit-only, watch authorization_grpc_denials and authorization_grpc_undeclared_methods settle to zero, then remove the option. (Exporters that suffix counters, Prometheus among them, will show these as _total.)
It is the only mode in which an unauthorized request proceeds, which is why it is a code-level option rather than configuration, and why it announces itself in the log at construction.
func WithLogger ¶
WithLogger attaches a logger, which the Enforcer uses for the denials that mean something is misconfigured: an undeclared method, and an RPC that requires authorization but carries no grants at all. It also carries the audit-only announcement at construction.
An ordinary denial, where the caller simply lacks a permission, is counted and attached to the span and stops there. It is enforcement working as designed, and a log line per occurrence is the wrong volume for that: the number worth alerting on is the counter.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider, enabling the authorization counters. Without it the counters are no-ops and an ordinary denial is visible only on the span, since nothing logs one.
type Requirements ¶
type Requirements struct {
// contains filtered or unexported fields
}
Requirements is the frozen table of what each RPC method demands.
It is immutable once built, which is why the interceptors need no lock. A mutable table guarded by a mutex costs a lock acquisition on every RPC to protect a map that is never written after startup.
func (*Requirements) Methods ¶
func (r *Requirements) Methods() []string
Methods returns every declared method name, sorted. It exists so consumers can assert their table covers every method their server registers — the check that turns "we remembered to declare everything" from a convention into a test.
type RequirementsBuilder ¶
type RequirementsBuilder struct {
// contains filtered or unexported fields
}
RequirementsBuilder accumulates method requirements and validates them as a whole.
func NewRequirements ¶
func NewRequirements() *RequirementsBuilder
NewRequirements returns a builder for a Requirements table.
func (*RequirementsBuilder) Build ¶
func (b *RequirementsBuilder) Build() (*Requirements, error)
Build validates the accumulated declarations and freezes them.
It reports every problem it found rather than the first, because a table assembled from a dozen service packages usually has more than one, and fixing them one restart at a time is miserable.
func (*RequirementsBuilder) Public ¶
func (b *RequirementsBuilder) Public(fullMethod string) *RequirementsBuilder
Public declares that fullMethod requires no authorization.
Being public is a declaration, never an omission. An undeclared method is denied, so forgetting to register a route fails closed and loudly, while forgetting to mark one public fails closed and obviously.
func (*RequirementsBuilder) Require ¶
func (b *RequirementsBuilder) Require(fullMethod string, perms ...authorization.Permission) *RequirementsBuilder
Require declares that fullMethod demands every permission in perms.
Requiring zero permissions is an error rather than a way to say "any authenticated caller" — it reads as a requirement while behaving as an allow, and that gap is where an authorization hole hides. Say Public instead, which means the same thing and looks like it.
func (*RequirementsBuilder) RequireAll ¶
func (b *RequirementsBuilder) RequireAll(m map[string][]authorization.Permission) *RequirementsBuilder
RequireAll declares requirements from a map, which is the shape a service package naturally exports for its own methods. Several such maps merge into one table, and a method declared by two of them is reported as a duplicate rather than silently taking whichever was applied last.