package ipcauth import ( "context" "os" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // Interceptor enforces per-RPC authorization on the daemon IPC, keyed to // the caller's kernel-authenticated identity. It is safe-by-default: // any RPC without a matching bypass is gated by the active profile's ownership, // and a caller without a readable identity is denied. type Interceptor struct { policy ProfilePolicy resolver GroupResolver // selfUID is the daemon's own effective UID. -1 on Windows. selfUID int } // NewInterceptor builds an interceptor over the given policy and group resolver. func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor { return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()} } // UnaryServerInterceptor authorizes each unary RPC before the handler runs. func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { if err := i.authorize(ctx, info.FullMethod); err != nil { return nil, err } return handler(ctx, req) } } // StreamServerInterceptor authorizes each streaming RPC before the handler runs. func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor { return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { if err := i.authorize(ss.Context(), info.FullMethod); err != nil { return err } return handler(srv, ss) } } // authorize decides each RPC from the caller's identity, first match wins: // // 0. no identity DENY // 1. self / privileged / forwarded ALLOW (root, elevated, daemon-self, JSON gateway) // 2. in ownersAuthorizedMethods owner tier (gate on DaemonOwnership) // 3. in handlerAuthorizedMethods profile tier (bypass, handler decides) // 4. everything else default tier (gate on ActiveProfileOwnership) func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error { id, ok := IdentityFromContext(ctx) if !ok { log.Warnf("ipc authz: DENY %s. caller identity unavailable", fullMethod) return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel") } if i.isSelfOrPrivileged(id) { // The local JSON gateway connects as the daemon itself (self/privileged) // and forwards the real HTTP client's identity. Trust it here, where // the transport peer is already the daemon, then authorize as the // forwarded client. A direct non-privileged caller never reaches this // branch, so it cannot forge the forwarding metadata. fwd, hasFwd := forwardedIdentity(ctx) if !hasFwd { i.auditAllow(id, fullMethod) return nil } log.Infof("ipc authz: gateway-forwarded identity %s", fwd) id = fwd if i.isSelfOrPrivileged(id) { i.auditAllow(id, fullMethod) return nil } } // Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner. if ownersAuthorizedMethods[fullMethod] { allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned) if err != nil { log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err) return status.Error(codes.Internal, "failed to claim daemon ownership") } if allowed { i.auditAllow(id, fullMethod) return nil } log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id) return status.Errorf(codes.PermissionDenied, "not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id) } // Profile tier: the handler self-authorizes against the target profile. if handlerAuthorizedMethods[fullMethod] { i.auditAllow(id, fullMethod) return nil } // Default: gated on the active profile's ownership. allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned) if err != nil { log.Errorf("ipc authz: claim active profile for %s: %v", id, err) return status.Error(codes.Internal, "failed to claim profile ownership") } if allowed { i.auditAllow(id, fullMethod) return nil } log.Warnf("ipc authz: DENY %s for %s. active profile owned by another principal", fullMethod, id) return status.Errorf(codes.PermissionDenied, "not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id) } // authorizeOwnership authorizes id against an ownership set, claiming it via // trust-on-first-use when it is unowned and unshared. The claim is atomic, so on // a lost race it re-reads and authorizes normally. func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) { o := get() if len(o.Owners) == 0 && !o.Shared { claimed, err := claim(id) if err != nil { return false, err } if claimed { return true, nil } o = get() } return Authorize(o, id, i.resolver), nil } // isSelfOrPrivileged reports whether the caller is the platform administrator // (root / elevated-admin / LocalSystem) or the daemon's own user. func (i *Interceptor) isSelfOrPrivileged(id Identity) bool { if id.IsPrivileged() { return true } // Daemon-self: only meaningful on Unix (Windows privilege is covered above). return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID } func (i *Interceptor) auditAllow(id Identity, fullMethod string) { if auditMethods[fullMethod] { log.Infof("ipc authz: allow %s for %s", fullMethod, id) } }