From 2df40fcc262e18364d77ad68da17d0ce576c3bd5 Mon Sep 17 00:00:00 2001 From: "Theodor S. Midtlien" Date: Tue, 8 Sep 2026 17:20:02 +0200 Subject: [PATCH] Add authorization framework for gRPC methods --- client/cmd/service.go | 2 +- client/cmd/service_controller.go | 8 +- client/internal/ipcauth/authz_gate.go | 167 ++++++++++++++++++ client/internal/ipcauth/authz_level.go | 59 +++++++ .../internal/ipcauth/identity_known_test.go | 61 ------- client/internal/ipcauth/methods.go | 118 +++++++++++++ client/internal/ipcauth/policy.go | 93 ---------- client/server/server.go | 5 + 8 files changed, 354 insertions(+), 159 deletions(-) create mode 100644 client/internal/ipcauth/authz_gate.go create mode 100644 client/internal/ipcauth/authz_level.go create mode 100644 client/internal/ipcauth/methods.go delete mode 100644 client/internal/ipcauth/policy.go diff --git a/client/cmd/service.go b/client/cmd/service.go index 2a2111c98..f1c65fe05 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -46,7 +46,7 @@ type program struct { jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex - ruleGate *ipcauth.RuleGate + authzGate *ipcauth.AuthzGate } func init() { diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 3a06ea7ff..55cf11d1f 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -80,12 +80,12 @@ func (p *program) Start(svc service.Service) error { return fmt.Errorf("parse daemon address: %w", err) } - p.ruleGate = ipcauth.NewRuleGate() + p.authzGate = ipcauth.NewAuthzGate() // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. opts := append(daemonServerOptions(network), - grpc.ChainUnaryInterceptor(p.ruleGate.UnaryPolicyInterceptor()), - grpc.ChainStreamInterceptor(p.ruleGate.StreamPolicyInterceptor()), + grpc.ChainUnaryInterceptor(p.authzGate.UnaryPolicyInterceptor()), + grpc.ChainStreamInterceptor(p.authzGate.StreamPolicyInterceptor()), ) p.serv = grpc.NewServer(opts...) @@ -151,7 +151,7 @@ func (p *program) serve(daemonListener, jsonListener *socketListener) error { } serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) - p.ruleGate.SetState(serverInstance) + p.authzGate.SetState(serverInstance) if err := serverInstance.Start(); err != nil { return fmt.Errorf("start daemon: %w", err) } diff --git a/client/internal/ipcauth/authz_gate.go b/client/internal/ipcauth/authz_gate.go new file mode 100644 index 000000000..299a5cfff --- /dev/null +++ b/client/internal/ipcauth/authz_gate.go @@ -0,0 +1,167 @@ +package ipcauth + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// DaemonState is used to surface the server state needed for determining +// authorization. +type DaemonState interface { + // SessionHolder returns the principal entitled to the live connection and + // whether one is held. + SessionHolder() (Principal, bool) + + OwnsProfile(id Identity, handle string) bool +} + +// AuthzGate authorizes every RPC call before its handler run. +type AuthzGate struct { + mu sync.Mutex + st DaemonState +} + +// NewAuthzGate returns a gate with no state attached. +func NewAuthzGate() *AuthzGate { + return &AuthzGate{} +} + +// SetState attaches the daemon state. Musy be called before serving RPCs. +func (g *AuthzGate) SetState(st DaemonState) { + g.mu.Lock() + defer g.mu.Unlock() + g.st = st +} + +func (g *AuthzGate) state() DaemonState { + g.mu.Lock() + defer g.mu.Unlock() + return g.st +} + +// RequireHolderForFullStatus is a Rule that enforces the right AuthzLevel if +// "full status" or "should run probes" are requested in a StatusRequest. +func RequireHolderForFullStatus(r Request) error { + full, ok := r.Msg.(interface{ GetGetFullPeerStatus() bool }) + if !ok || !full.GetGetFullPeerStatus() { + return nil + } + probes, ok := r.Msg.(interface{ ShouldRunProbes() bool }) + if !ok || !probes.ShouldRunProbes() { + return nil + } + return RequireLevel(AuthzLevelSessionHolder)(r) +} + +// RequireLevel builds a rule from a level, for composing inside another rule. +func RequireLevel(want AuthzLevel) Rule { + return func(r Request) error { + if r.Level >= want { + return nil + } + return denyLevel(r, want) + } +} + +// RequireFlowInitiator binds a pending authentication flow to the identity that +// started it. +func RequireFlowInitiator(r Request) error { + // TODO: needs the flow registry keyed by initiator. + return status.Error(codes.Unimplemented, "pending flows are not yet caller-bound") +} + +func denyLevel(r Request, want AuthzLevel) error { + return status.Errorf(codes.PermissionDenied, + "%s requires %s, caller %s is %s", r.Method, want, r.Identity, r.Level) +} + +// StreamPolicyInterceptor authorizes each streaming RPC before the handler runs. +// The request payload is not yet available, so no streaming method may be +// target-scoped. +func (g *AuthzGate) StreamPolicyInterceptor() grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + authCtx, authErr := g.authorize(ss.Context(), info.FullMethod, nil) + if authErr != nil { + return authErr + } + return handler(srv, &authorizedStream{ServerStream: ss, ctx: authCtx}) + } +} + +// UnaryPolicyInterceptor authorizes each unary RPC before the handler runs. +func (g *AuthzGate) UnaryPolicyInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { + authCtx, authErr := g.authorize(ctx, info.FullMethod, req) + if authErr != nil { + return nil, authErr + } + return handler(authCtx, req) + } +} + +func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (context.Context, error) { + id, ok := CallerIdentity(ctx) + if !ok { + log.Warnf("ipc authz: DENY %s, caller identity unavailable", method) + return nil, status.Error(codes.PermissionDenied, + "caller identity could not be verified on the daemon control channel") + } + st := g.state() + if st == nil { + log.Warnf("ipc authz: DENY %s for %s, daemon state not attached", method, id) + return nil, status.Error(codes.Unavailable, "daemon not initialized") + } + target, named := targetProfile(msg) + policy := methodPolicyFor(method) + if policy.TargetsProfile && !named { + return nil, status.Errorf(codes.Internal, "%s is declared target-scoped but names no profile", method) + } + auth := Authorization{ + Identity: id, + Level: resolveLevel(id, target, st), + Target: target, + Method: method, + } + req := Request{Authorization: auth, State: st, Msg: msg} + if req.Level < policy.Level { + log.Warnf("ipc authz: DENY %s for %s (%s), requires %s", method, id, req.Level, policy.Level) + return nil, denyLevel(req, policy.Level) + } + for _, rule := range policy.Rules { + if err := rule(req); err != nil { + log.Warnf("ipc authz: DENY %s for %s (%s): %v", method, id, req.Level, err) + return nil, err + } + } + if policy.Audit { + log.Infof("ipc authz: allow %s for %s (%s)", method, id, req.Level) + } + return withAuthorization(ctx, auth), nil +} + +// authorizedStream carries the authorized context into a streaming handler, +// which would otherwise see the one the stream was created with. +type authorizedStream struct { + grpc.ServerStream + ctx context.Context +} + +type authorizationKey struct{} + +func withAuthorization(ctx context.Context, a Authorization) context.Context { + return context.WithValue(ctx, authorizationKey{}, a) +} + +// Authorized returns the decision the interceptor made for this RPC. +// +// Read it to decide what a caller sees or who an action is attributed to. Do not +// read it to decide whether a call is allowed, which is the method table's job. +func Authorized(ctx context.Context) Authorization { + a, _ := ctx.Value(authorizationKey{}).(Authorization) + return a +} diff --git a/client/internal/ipcauth/authz_level.go b/client/internal/ipcauth/authz_level.go new file mode 100644 index 000000000..09850fcaf --- /dev/null +++ b/client/internal/ipcauth/authz_level.go @@ -0,0 +1,59 @@ +package ipcauth + +// AuthzLevel is the authority a caller holds over the daemon's current state. +// The values are ordered, and each level can do everything the levles below +// it can. A MethodPolicy is satisfied when the caller's level is at least +// the level the method requires. +type AuthzLevel uint8 + +const ( + // AuthzLevelNone is a caller whose kernel identity could not be established. + AuthzLevelNone AuthzLevel = iota + + // AuthzLevelIdentified is any caller the kernel could verify. + AuthzLevelIdentified + + // AuthzLevelProfileOwner is a caller being the owner of the current targeted + // profile + AuthzLevelProfileOwner + + // AuthzLevelSessionHolder is a caller that owns the current active profile + // and the session is currently connected (after running UP). + AuthzLevelSessionHolder + + // AuthzLevelPrivileged is root, an elevated administrator or the daemon's + // own identity (if running as less privileges than root). + AuthzLevelPrivileged +) + +// Strint() resolves a AuthzLevel to a human readable debug string. +func (l AuthzLevel) String() string { + switch l { + case AuthzLevelIdentified: + return "identified" + case AuthzLevelProfileOwner: + return "profile owner" + case AuthzLevelSessionHolder: + return "session holder" + case AuthzLevelPrivileged: + return "privileged" + default: + return "unidentified" + } +} + +func resolveLevel(id Identity, target string, st DaemonState) AuthzLevel { + if !id.Known() { + return AuthzLevelNone + } + if IsPrivilegedCaller(id) { + return AuthzLevelPrivileged + } + if !st.OwnsProfile(id, target) { + return AuthzLevelIdentified + } + if holder, running := st.SessionHolder(); !running || holder.Matches(id) { + return AuthzLevelSessionHolder + } + return AuthzLevelProfileOwner +} diff --git a/client/internal/ipcauth/identity_known_test.go b/client/internal/ipcauth/identity_known_test.go index 57e2f7e90..0ce269f3a 100644 --- a/client/internal/ipcauth/identity_known_test.go +++ b/client/internal/ipcauth/identity_known_test.go @@ -5,8 +5,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // The zero Identity carries uid 0, so every predicate that reads UID has to @@ -55,65 +53,6 @@ func TestKnownForTestMarksIdentity(t *testing.T) { assert.Equal(t, uint32(1000), id.UID, "KnownForTest must not alter the identity") } -type stubState struct { - holder Principal - held bool -} - -func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.held } - -func uidHolder(uid uint32) Principal { - p, ok := ParsePrincipal(UIDPrincipal(uid)) - if !ok { - panic("bad uid principal") - } - return p -} - -// The holder comes from a profile JSON, the caller from a peercred read. When -// both were an Identity, the known marker made every such comparison false and -// the session holder could never be recognised. -func TestRequireSessionHolderMatchesConfigOwner(t *testing.T) { - asDaemon(t, KnownForTest(Identity{UID: 0})) - - caller := KnownForTest(Identity{UID: 1000, GID: 1000}) - assert.NoError(t, RequireSessionHolder(caller, stubState{holder: uidHolder(1000), held: true})) -} - -func TestRequireSessionHolderRejectsAnotherUser(t *testing.T) { - asDaemon(t, KnownForTest(Identity{UID: 0})) - - caller := KnownForTest(Identity{UID: 1000, GID: 1000}) - err := RequireSessionHolder(caller, stubState{holder: uidHolder(1001), held: true}) - assert.Equal(t, codes.PermissionDenied, status.Code(err)) -} - -// With nobody connected there is no session to protect. -func TestRequireSessionHolderAllowsWhenUnheld(t *testing.T) { - asDaemon(t, KnownForTest(Identity{UID: 0})) - - caller := KnownForTest(Identity{UID: 1000, GID: 1000}) - assert.NoError(t, RequireSessionHolder(caller, stubState{held: false})) -} - -// An owner that could not be parsed leaves the zero Principal, which matches -// nobody, so the session locks rather than opening. -func TestRequireSessionHolderLocksOnUnparseableOwner(t *testing.T) { - asDaemon(t, KnownForTest(Identity{UID: 0})) - - caller := KnownForTest(Identity{UID: 1000, GID: 1000}) - err := RequireSessionHolder(caller, stubState{holder: Principal{}, held: true}) - assert.Equal(t, codes.PermissionDenied, status.Code(err)) -} - -// Root takes over whoever holds the session. -func TestRequireSessionHolderAllowsPrivilegedCaller(t *testing.T) { - asDaemon(t, KnownForTest(Identity{UID: 1000})) - - root := KnownForTest(Identity{UID: 0}) - assert.NoError(t, RequireSessionHolder(root, stubState{holder: uidHolder(1001), held: true})) -} - // A uid:0 owner is a config value, so it grants nothing on its own. func TestConfigOwnerCannotGrantPrivilege(t *testing.T) { root, ok := ParsePrincipal("uid:0") diff --git a/client/internal/ipcauth/methods.go b/client/internal/ipcauth/methods.go new file mode 100644 index 000000000..49baf302b --- /dev/null +++ b/client/internal/ipcauth/methods.go @@ -0,0 +1,118 @@ +package ipcauth + +const servicePath = "/daemon.DaemonService/" + +// Authorization is the decision the interceptor reached for one RPC. It is what +// handlers read. +type Authorization struct { + Identity Identity + Level AuthzLevel + Target string + Method string +} + +// Request is what a rule decides on: the authorization plus the state and the +// message, which only the gate needs. +type Request struct { + Authorization + State DaemonState + Method string + Msg any +} + +// Rule is an additional constraint beyond the method's level. Every rule on a +// method must pass. +type Rule func(Request) error + +// The generated getters the profile RPCs expose. +type handleTargeted interface{ GetHandle() string } +type profileTargeted interface{ GetProfileName() string } + +// targetProfile returns the profile a request names, and whether it carries a +// target field at all. Requests with no target act on the active profile. +func targetProfile(msg any) (string, bool) { + switch m := msg.(type) { + case handleTargeted: + return m.GetHandle(), true + case profileTargeted: + return m.GetProfileName(), true + default: + return "", false + } +} + +// MethodPolicy is what a method requires to be authorized and then handled. +type MethodPolicy struct { + Level AuthzLevel + Rules []Rule + Audit bool + TargetsProfile bool +} + +// methodPolicies is the complete authorization surface. Every RPC on +// DaemonService appears here exactly once. +var methodPolicies = map[string]MethodPolicy{ + // Any identified caller. + servicePath + "Status": {Level: AuthzLevelIdentified, Rules: []Rule{RequireHolderForFullStatus}}, + servicePath + "AddProfile": {Level: AuthzLevelIdentified, Audit: true}, + servicePath + "GetActiveProfile": {Level: AuthzLevelIdentified}, + servicePath + "GetFeatures": {Level: AuthzLevelIdentified}, + servicePath + "WailsUIReady": {Level: AuthzLevelIdentified}, + + // Pending flows: bound to the principal that started them, at any level. + servicePath + "WaitSSOLogin": {Level: AuthzLevelIdentified, Rules: []Rule{RequireFlowInitiator}, Audit: true}, + servicePath + "WaitJWTToken": {Level: AuthzLevelIdentified, Rules: []Rule{RequireFlowInitiator}, Audit: true}, + servicePath + "WaitExtendAuthSession": {Level: AuthzLevelIdentified, Rules: []Rule{RequireFlowInitiator}}, + + // Owner of the profile the request names. + servicePath + "GetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, + servicePath + "SetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, + servicePath + "Login": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, + servicePath + "Logout": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, + servicePath + "RenameProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true}, + servicePath + "RemoveProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, + servicePath + "SwitchProfile": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, + + // Owner of some profile + servicePath + "ListProfiles": {Level: AuthzLevelProfileOwner}, + servicePath + "GetLogLevel": {Level: AuthzLevelProfileOwner}, + servicePath + "ListStates": {Level: AuthzLevelProfileOwner}, + servicePath + "GetInstallerResult": {Level: AuthzLevelProfileOwner}, + + // Session holder: the live engine and everything daemon-wide. + servicePath + "Up": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, + servicePath + "Down": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "SubscribeStatus": {Level: AuthzLevelSessionHolder}, + servicePath + "SubscribeEvents": {Level: AuthzLevelSessionHolder}, + servicePath + "GetEvents": {Level: AuthzLevelSessionHolder}, + servicePath + "ListNetworks": {Level: AuthzLevelSessionHolder}, + servicePath + "SelectNetworks": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "DeselectNetworks": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "ForwardingRules": {Level: AuthzLevelSessionHolder}, + servicePath + "ExposeService": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "GetPeerSSHHostKey": {Level: AuthzLevelSessionHolder}, + servicePath + "RequestJWTAuth": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "RequestExtendAuthSession": {Level: AuthzLevelSessionHolder}, + servicePath + "DismissSessionWarning": {Level: AuthzLevelSessionHolder}, + servicePath + "DebugBundle": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "SetLogLevel": {Level: AuthzLevelSessionHolder}, + servicePath + "SetSyncResponsePersistence": {Level: AuthzLevelSessionHolder}, + servicePath + "StartCapture": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "StartBundleCapture": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "StopBundleCapture": {Level: AuthzLevelSessionHolder}, + servicePath + "StartCPUProfile": {Level: AuthzLevelSessionHolder}, + servicePath + "StopCPUProfile": {Level: AuthzLevelSessionHolder}, + servicePath + "CleanState": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "DeleteState": {Level: AuthzLevelSessionHolder, Audit: true}, + servicePath + "TracePacket": {Level: AuthzLevelSessionHolder}, + servicePath + "RegisterUILog": {Level: AuthzLevelSessionHolder}, + servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true}, +} + +func methodPolicyFor(method string) MethodPolicy { + if p, ok := methodPolicies[method]; ok { + return p + } + // TODO: reconsider falling back to Privileged rather than direct DENY. + return MethodPolicy{Level: AuthzLevelPrivileged, Audit: true} +} diff --git a/client/internal/ipcauth/policy.go b/client/internal/ipcauth/policy.go deleted file mode 100644 index 39616c731..000000000 --- a/client/internal/ipcauth/policy.go +++ /dev/null @@ -1,93 +0,0 @@ -package ipcauth - -import ( - "context" - "sync" - - log "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type DaemonState interface { - // SessionHolder returns the principal entitled to the live connection and - // whether one is held. A held session whose owner cannot be parsed returns - // the zero Principal with true, which matches nobody, so a corrupt owner - // field locks the session instead of opening it. - SessionHolder() (Principal, bool) -} - -type Rule func(id Identity, st DaemonState) error - -type RuleGate struct { - mu sync.Mutex - rules []Rule - st DaemonState -} - -func NewRuleGate() *RuleGate { - return &RuleGate{} -} - -func (g *RuleGate) SetState(st DaemonState) { - g.mu.Lock() - defer g.mu.Unlock() - g.st = st -} - -func (g *RuleGate) SetRule(r Rule) { - g.mu.Lock() - defer g.mu.Unlock() - g.rules = append(g.rules, r) -} - -func (g *RuleGate) state() DaemonState { - g.mu.Lock() - defer g.mu.Unlock() - return g.st -} - -// RequireSessionHolder allows the caller to act on the live connection. With no -// session held there is nothing to protect, and root can always take over. -func RequireSessionHolder(id Identity, st DaemonState) error { - holder, running := st.SessionHolder() - if !running || IsPrivilegedCaller(id) || holder.Matches(id) { - return nil - } - log.Debugf("caller %v is not the session holder %v", id, holder) - return status.Errorf(codes.PermissionDenied, "session is held by another user (%v)", holder) -} - -func (g *RuleGate) StreamPolicyInterceptor() grpc.StreamServerInterceptor { - return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - if authErr := g.authorize(ss.Context()); authErr != nil { - return authErr - } - return handler(ss.Context(), ss) - } -} - -func (g *RuleGate) UnaryPolicyInterceptor() grpc.UnaryServerInterceptor { - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { - if authErr := g.authorize(ctx); authErr != nil { - return nil, authErr - } - return handler(ctx, req) - } -} - -func (g *RuleGate) authorize(ctx context.Context) error { - id, ok := CallerIdentity(ctx) - if !ok { - return status.Error(codes.PermissionDenied, "caller cannot be verified") - } - state := g.state() - for _, rule := range g.rules { - ruleErr := rule(id, state) - if ruleErr != nil { - return ruleErr - } - } - return nil -} diff --git a/client/server/server.go b/client/server/server.go index 86a0eabf5..3f3e96848 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -2723,6 +2723,11 @@ func (s *Server) SessionHolder() (ipcauth.Principal, bool) { return principal, true } +func (s *Server) OwnsProfile(id ipcauth.Identity, handle string) bool { + // TODO + return false +} + func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { if preSharedKey != nil && *preSharedKey == "" { preSharedKey = nil