Move profile resolution to the authz gate

This commit is contained in:
Theodor S. Midtlien
2026-09-18 11:19:35 +02:00
parent 96f9ea7428
commit 6dcb1374e9
18 changed files with 518 additions and 332 deletions
+37 -22
View File
@@ -18,11 +18,12 @@ type DaemonState interface {
// whether one is held.
SessionHolder() (Principal, bool)
// OwnsProfile reports whether id owns the profile a request names. An empty
// handle is the active profile, which is what a method that acts on the
// live session resolves against. The error says what was wrong with the
// handle itself.
OwnsProfile(id Identity, handle string) (bool, error)
// ResolveTarget resolves the profile a request names to a concrete profile
// and reports whether the caller may address it. An empty handle is the
// active profile.
//
// The error says what was wrong with the handle itself.
ResolveTarget(id Identity, handle string) (Target, error)
}
// AuthzGate authorizes every RPC call before its handler run.
@@ -118,8 +119,9 @@ func denyPolicyLevel(r Request, p MethodPolicy) error {
// target-scoped.
func (g *AuthzGate) StreamPolicyInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
authErr := g.authorize(ss.Context(), info.FullMethod, nil)
if authErr != nil {
// A stream's context cannot be replaced from here. We use context to pass
// resolved target profile and no streaming method may be target-scoped.
if _, authErr := g.authorize(ss.Context(), info.FullMethod, nil); authErr != nil {
return authErr
}
return handler(srv, ss)
@@ -129,7 +131,7 @@ func (g *AuthzGate) StreamPolicyInterceptor() grpc.StreamServerInterceptor {
// 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) {
authErr := g.authorize(ctx, info.FullMethod, req)
ctx, authErr := g.authorize(ctx, info.FullMethod, req)
if authErr != nil {
return nil, authErr
}
@@ -137,55 +139,68 @@ func (g *AuthzGate) UnaryPolicyInterceptor() grpc.UnaryServerInterceptor {
}
}
func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) error {
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 status.Error(codes.PermissionDenied,
return ctx, 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 status.Error(codes.Unavailable, "daemon not initialized")
return ctx, status.Error(codes.Unavailable, "daemon not initialized")
}
policy := methodPolicyFor(method)
// Only a target-scoped method reads a profile off the request.
var target string
// Only a target-scoped method reads a profile off the request. Everything
// else acts on the active profile, which an empty handle resolves to.
var handle string
if policy.TargetsProfile {
named, ok := targetProfile(msg)
if !ok {
return status.Errorf(codes.Internal, "%s is declared target-scoped but names no profile", method)
return ctx, status.Errorf(codes.Internal, "%s is declared target-scoped but names no profile", method)
}
target = named
handle = named
}
level, resolveErr := resolveLevel(id, target, st)
target, handleErr := st.ResolveTarget(id, handle)
level := resolveLevel(id, target, st)
if handleErr != nil && handle != "" {
level = AuthzLevelIdentified
}
req := Request{
Identity: id,
Level: level,
Target: target,
Target: handle,
Method: method,
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)
if resolveErr != nil {
return resolveErr
if presentable := presentableHandleError(handle, handleErr); presentable != nil {
return ctx, presentable
}
return denyPolicyLevel(req, policy)
return ctx, denyPolicyLevel(req, policy)
}
for _, rule := range policy.Rules {
if err := rule(req); err != nil {
log.Warnf("ipc authz: DENY %s for %s (%s): error", method, id, req.Level)
return err
return ctx, err
}
}
if policy.Audit {
log.Infof("ipc authz: allow %s for %s (%s)", method, id, req.Level)
}
return nil
if !target.Owned {
// Reaching here means the method was open to the caller's level
// without owning anything, so there is no authorized profile to hand
// the handler.
return ctx, nil
}
return ContextWithTarget(ctx, target.Path), nil
}
+53 -23
View File
@@ -35,9 +35,9 @@ func switchTo(handle string) *proto.SwitchProfileRequest {
// exists and belongs to somebody, which a mistyped handle does not.
func TestAuthorizeSurfacesWhatIsWrongWithTheHandle(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "asdfasdfasdf")
g := gateFor(t, stubState{ownsErr: notFound})
g := gateFor(t, stubState{targetErr: notFound})
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("asdfasdfasdf"))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("asdfasdfasdf"))
require.Error(t, err)
st := gstatus.Convert(err)
@@ -52,9 +52,9 @@ func TestAuthorizeSurfacesWhatIsWrongWithTheHandle(t *testing.T) {
// error, and the CLI reformats it into a hint. It has to reach the CLI.
func TestAuthorizeSurfacesAnAmbiguousHandle(t *testing.T) {
ambiguous := gstatus.Errorf(codes.InvalidArgument, "handle %q matches 2 profiles", "ab")
g := gateFor(t, stubState{ownsErr: ambiguous})
g := gateFor(t, stubState{targetErr: ambiguous})
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("ab"))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("ab"))
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code())
}
@@ -64,9 +64,9 @@ func TestAuthorizeSurfacesAnAmbiguousHandle(t *testing.T) {
// the refusal stays about who the profile belongs to.
func TestAuthorizeBlamesOwnershipForTheActiveProfile(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id")
g := gateFor(t, stubState{ownsErr: notFound})
g := gateFor(t, stubState{targetErr: notFound})
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo(""))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo(""))
require.Error(t, err)
denial, ok := DenialFrom(err)
@@ -78,9 +78,9 @@ func TestAuthorizeBlamesOwnershipForTheActiveProfile(t *testing.T) {
// A daemon-side failure is not something the caller can correct, and putting it
// on the wire would describe the daemon rather than the request.
func TestAuthorizeKeepsADaemonFailureOffTheWire(t *testing.T) {
g := gateFor(t, stubState{ownsErr: errors.New("read profile directory: permission denied")})
g := gateFor(t, stubState{targetErr: errors.New("read profile directory: permission denied")})
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
require.Error(t, err)
denial, ok := DenialFrom(err)
@@ -93,14 +93,15 @@ func TestAuthorizeKeepsADaemonFailureOffTheWire(t *testing.T) {
// identified caller may make. A failure there must not take those down.
func TestAuthorizeAllowsIdentifiedMethodsDespiteAResolveFailure(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id")
g := gateFor(t, stubState{ownsErr: notFound})
g := gateFor(t, stubState{targetErr: notFound})
for _, method := range []string{"ListProfiles", "AddProfile", "GetActiveProfile", "GetFeatures"} {
t.Run(method, func(t *testing.T) {
require.Equal(t, AuthzLevelIdentified, methodPolicies[servicePath+method].Level,
"fixture is wrong: %s is no longer open to any identified caller", method)
assert.NoError(t, g.authorize(transportCtx(unprivUser, nil), servicePath+method, nil))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+method, nil)
assert.NoError(t, err)
})
}
}
@@ -108,32 +109,61 @@ func TestAuthorizeAllowsIdentifiedMethodsDespiteAResolveFailure(t *testing.T) {
// Ownership is the gate's answer, never the error's: a resolution that failed is
// a no whatever it returned alongside.
func TestAuthorizeRefusesWhenResolutionFails(t *testing.T) {
g := gateFor(t, stubState{owns: false, ownsErr: gstatus.Error(codes.NotFound, "profile not found")})
g := gateFor(t, stubState{targetErr: gstatus.Error(codes.NotFound, "profile not found")})
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
assert.Error(t, err, "an error from the resolution cannot be read as ownership")
}
// A resolution that failed established nothing about the profile, so no level
// returned alongside the error may be acted on. This is the invariant the gate
// clamps, pinned at the function that has to hold it.
// A resolution that failed established nothing about the profile, so ownership
// reported alongside the error may not be acted on. A state that answers both
// at once is exactly what this refuses to trust.
func TestResolveLevelNeverRaisesTheLevelOnAFailure(t *testing.T) {
asDaemon(t, root)
notFound := gstatus.Error(codes.NotFound, "profile not found")
owned := Target{Path: "/profiles/some-profile.json", Owned: true}
for _, tc := range []struct {
name string
st stubState
}{
{"a live session it reports as owned", stubState{owns: true, running: true, ownsErr: notFound}},
{"an idle daemon it reports as owned", stubState{owns: true, ownsErr: notFound}},
{"a daemon-side failure it reports as owned", stubState{owns: true, ownsErr: errors.New("read profile directory")}},
{"a live session it reports as owned", stubState{target: owned, running: true, targetErr: notFound}},
{"an idle daemon it reports as owned", stubState{target: owned, targetErr: notFound}},
{"a daemon-side failure it reports as owned", stubState{target: owned, targetErr: errors.New("read profile directory")}},
} {
t.Run(tc.name, func(t *testing.T) {
level, _ := resolveLevel(unprivUser, "some-profile", tc.st)
assert.Equal(t, AuthzLevelIdentified, level,
"a failed resolution cannot confer %s", level)
g := gateFor(t, tc.st)
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
assert.Error(t, err, "a failed resolution conferred a level it had no business conferring")
})
}
}
// The profile the gate resolved is what the handler acts on, so it has to reach
// the handler. Resolving the handle a second time downstream is what this
// exists to make unnecessary.
func TestAuthorizeCarriesTheResolvedTargetToTheHandler(t *testing.T) {
g := gateFor(t, stubState{target: Target{Path: "/profiles/abcd1111.json", Owned: true}})
ctx, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("work"))
require.NoError(t, err)
got, ok := TargetFromContext(ctx)
require.True(t, ok, "the handler has no profile to act on")
assert.Equal(t, "/profiles/abcd1111.json", got,
"the handler would act on a different profile than the one authorized")
}
// A privileged caller skips the ownership question but still needs the profile
// their handle named, or every target-scoped RPC breaks under sudo.
func TestAuthorizeCarriesTheTargetForAPrivilegedCaller(t *testing.T) {
g := gateFor(t, stubState{target: Target{Path: "/profiles/abcd1111.json", Owned: true}})
ctx, err := g.authorize(transportCtx(root, nil), servicePath+"ClaimProfile",
&proto.ClaimProfileRequest{Handle: "work"})
require.NoError(t, err)
got, ok := TargetFromContext(ctx)
require.True(t, ok, "root resolved nothing to act on")
assert.Equal(t, "/profiles/abcd1111.json", got)
}
+19 -18
View File
@@ -47,41 +47,42 @@ func (l AuthzLevel) String() string {
}
// resolveLevel is the authority the caller holds over the profile the request
// names. The second return is what was wrong with the handle, when that is
// worth showing the caller instead of a refusal. It never raises the level: a
// resolution that failed still denies.
func resolveLevel(id Identity, target string, st DaemonState) (AuthzLevel, error) {
// resolved to. A profile the caller does not own confers nothing beyond being
// identified, which is also what an unresolved handle leaves them with.
func resolveLevel(id Identity, target Target, st DaemonState) AuthzLevel {
if !id.Known() {
return AuthzLevelNone, nil
return AuthzLevelNone
}
if IsPrivilegedCaller(id) {
return AuthzLevelPrivileged, nil
return AuthzLevelPrivileged
}
ownsProfile, err := st.OwnsProfile(id, target)
if err != nil {
return AuthzLevelIdentified, presentableHandleError(target, err)
}
if !ownsProfile {
return AuthzLevelIdentified, nil
if !target.Owned {
return AuthzLevelIdentified
}
if holder, running := st.SessionHolder(); !running || holder.Matches(id) {
return AuthzLevelSessionHolder, nil
return AuthzLevelSessionHolder
}
return AuthzLevelProfileOwner, nil
return AuthzLevelProfileOwner
}
// presentableHandleError keeps a resolution failure only when the gate can put
// it in front of the caller in place of its own refusal. Everything else is
// dropped, and the caller gets the refusal their level earned.
func presentableHandleError(target string, err error) error {
// An empty target is the active profile rather than something the caller
func presentableHandleError(handle string, err error) error {
if err == nil {
return nil
}
// An empty handle is the active profile rather than something the caller
// typed, so a failure to resolve it is not theirs to correct.
if target == "" {
if handle == "" {
return nil
}
// Only a gRPC status reaches the caller as a sentence the CLI and the UI
// render.
// render. A plain error is a daemon-side failure, and putting it on the
// wire would tell the caller about the daemon rather than about the handle
// they gave.
if _, ok := gstatus.FromError(err); !ok {
return nil
}
@@ -102,14 +102,15 @@ func TestDenyPolicyLevelWithoutGuidanceStaysBare(t *testing.T) {
// stubState stands in for the daemon so a denial can be built without a server.
type stubState struct {
holder Principal
running bool
owns bool
ownsErr error
holder Principal
running bool
target Target
targetErr error
}
func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.running }
func (s stubState) OwnsProfile(Identity, string) (bool, error) { return s.owns, s.ownsErr }
func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.running }
func (s stubState) ResolveTarget(Identity, string) (Target, error) { return s.target, s.targetErr }
// A refusal caused by somebody else's connection explains itself and offers no
// command, since the caller cannot end a session that is not theirs.
+26
View File
@@ -0,0 +1,26 @@
package ipcauth
import "context"
// Target is the profile a request resolved to.
type Target struct {
Path string
Owned bool
}
type targetKey struct{}
// ContextWithTarget carries the profile the gate resolved, so a handler acts on
// the profile that was authorized rather than resolving the caller's handle a
// second time.
func ContextWithTarget(ctx context.Context, path string) context.Context {
return context.WithValue(ctx, targetKey{}, path)
}
// TargetFromContext returns the file of the profile the gate resolved for this
// request. It reports false when nothing resolved, which a handler acting on a
// named profile must treat as a refusal rather than as the active profile.
func TargetFromContext(ctx context.Context) (string, bool) {
path, ok := ctx.Value(targetKey{}).(string)
return path, ok && path != ""
}
+1 -1
View File
@@ -151,7 +151,7 @@ func TestRemoveProfile_DeletesPrefsFile(t *testing.T) {
_, err = os.Stat(prefsPath)
require.NoError(t, err)
require.NoError(t, sm.RemoveProfile(created.ID, userID))
require.NoError(t, sm.RemoveProfile(created.ID))
_, err = os.Stat(prefsPath)
assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed")
})
+70 -51
View File
@@ -49,6 +49,13 @@ type ErrAmbiguousHandle struct {
Kind AmbiguityKind
}
// HandleMatch is the set of profiles a handle matched and which matcher found
// them. Kind only carries meaning when more than one profile matched.
type HandleMatch struct {
Profiles []Profile
Kind AmbiguityKind
}
// AmbiguityKind describes which matcher produced the ambiguity, so callers
// can tailor the error message.
type AmbiguityKind int
@@ -436,20 +443,9 @@ func (s *ServiceManager) RenameProfile(id ID, newName string) error {
return fmt.Errorf("invalid profile ID: %q", id)
}
profiles, err := s.loadAllProfiles()
target, err := s.ProfileByID(id)
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
var target *Profile
for i := range profiles {
if profiles[i].ID == id {
target = &profiles[i]
break
}
}
if target == nil {
return ErrProfileNotFound
return err
}
return writeProfileName(target.Path, displayName)
@@ -470,20 +466,9 @@ func (s *ServiceManager) RemoveProfile(id ID) error {
return fmt.Errorf("invalid profile ID: %q", id)
}
profiles, err := s.loadAllProfiles()
target, err := s.ProfileByID(id)
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
var target *Profile
for i := range profiles {
if profiles[i].ID == id {
target = &profiles[i]
break
}
}
if target == nil {
return ErrProfileNotFound
return err
}
activeProf, err := s.GetActiveProfileState()
@@ -676,7 +661,7 @@ func (s *ServiceManager) ClaimDefaultProfileIfNeeded(id ipcauth.Identity) {
profiles, err := s.loadAllProfiles()
if err != nil {
log.Warnf("could not load all profiles: %w", err)
log.Warnf("could not load all profiles: %v", err)
return
}
@@ -1061,25 +1046,31 @@ func (s *ServiceManager) activeProfileID() (ID, bool) {
return state.ID, false
}
// ResolveProfile turns a user-supplied handle into a Profile. Resolution
// precedence is: exact ID match, then unique exact name, then unique ID
// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
// surface the candidates.
func (s *ServiceManager) ResolveProfile(handle string) (*Profile, error) {
// MatchProfiles returns every profile a user-supplied handle matches, at the
// highest precedence tier that matched at all: exact ID, then exact name, then
// ID prefix. It answers existence and nothing else, so choosing between several
// matches is left to the caller that knows who is asking.
func (s *ServiceManager) MatchProfiles(handle string) (HandleMatch, error) {
if handle == "" {
return nil, fmt.Errorf("profile handle is empty")
return HandleMatch{}, fmt.Errorf("profile handle is empty")
}
profiles, err := s.loadAllProfiles()
if err != nil {
return nil, err
return HandleMatch{}, err
}
// A legacy ID is a display name two accounts can hold in their own profile
// directories, so even an exact ID can match more than one file.
var idMatches []Profile
for i := range profiles {
if profiles[i].ID == ID(handle) {
return &profiles[i], nil
idMatches = append(idMatches, profiles[i])
}
}
if len(idMatches) > 0 {
return HandleMatch{Profiles: idMatches, Kind: AmbiguityKindName}, nil
}
var nameMatches []Profile
for i := range profiles {
@@ -1087,15 +1078,8 @@ func (s *ServiceManager) ResolveProfile(handle string) (*Profile, error) {
nameMatches = append(nameMatches, profiles[i])
}
}
if len(nameMatches) == 1 {
return &nameMatches[0], nil
}
if len(nameMatches) > 1 {
return nil, &ErrAmbiguousHandle{
Handle: handle,
Candidates: nameMatches,
Kind: AmbiguityKindName,
}
if len(nameMatches) > 0 {
return HandleMatch{Profiles: nameMatches, Kind: AmbiguityKindName}, nil
}
// ID prefix match. Skip the default profile so `select d` does not
@@ -1109,14 +1093,49 @@ func (s *ServiceManager) ResolveProfile(handle string) (*Profile, error) {
prefixMatches = append(prefixMatches, profiles[i])
}
}
if len(prefixMatches) == 1 {
return &prefixMatches[0], nil
if len(prefixMatches) > 0 {
return HandleMatch{Profiles: prefixMatches, Kind: AmbiguityKindIDPrefix}, nil
}
if len(prefixMatches) > 1 {
return nil, &ErrAmbiguousHandle{
Handle: handle,
Candidates: prefixMatches,
Kind: AmbiguityKindIDPrefix,
return HandleMatch{}, ErrProfileNotFound
}
// ProfileByPath returns the profile stored at this path.
func (s *ServiceManager) ProfileByPath(path string) (*Profile, error) {
if path == "" {
return nil, fmt.Errorf("profile path is empty")
}
profiles, err := s.loadAllProfiles()
if err != nil {
return nil, err
}
for i := range profiles {
if profiles[i].Path == path {
return &profiles[i], nil
}
}
return nil, ErrProfileNotFound
}
// ProfileByID returns the first profile with this ID. Only a caller that has no
// second profile to confuse it with may use this: a legacy ID is a display name
// and two accounts can hold the same one. Prefer ProfileByPath.
func (s *ServiceManager) ProfileByID(id ID) (*Profile, error) {
if id == "" {
return nil, fmt.Errorf("profile ID is empty")
}
profiles, err := s.loadAllProfiles()
if err != nil {
return nil, err
}
for i := range profiles {
if profiles[i].ID == id {
return &profiles[i], nil
}
}
+64 -90
View File
@@ -39,6 +39,31 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, id ipcauth.Identity))
// The identity the helper hands out has to work as a profile owner on the
// platform the suite is running on.
// matchOne is the single profile a handle matches, for the tests that are about
// the matcher's precedence rather than about who is asking.
func matchOne(t *testing.T, sm *ServiceManager, handle string) Profile {
t.Helper()
match, err := sm.MatchProfiles(handle)
require.NoError(t, err)
require.Len(t, match.Profiles, 1, "handle %q did not match exactly one profile", handle)
return match.Profiles[0]
}
// claimAndList is the sequence a request goes through now: the gate stamps
// whatever the caller can claim, and only then is the listing filtered by what
// they own. Listing on its own no longer claims.
func claimAndList(t *testing.T, sm *ServiceManager, id ipcauth.Identity) []Profile {
t.Helper()
sm.ClaimDefaultProfileIfNeeded(id)
sm.ClaimLegacyProfiles(id)
profiles, err := sm.ListProfiles(id)
require.NoError(t, err)
return profiles
}
func TestWithTestSM_ScopesToAUsableOwner(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, id ipcauth.Identity) {
require.True(t, id.Known(), "every test in this package authorizes against this identity")
@@ -46,7 +71,7 @@ func TestWithTestSM_ScopesToAUsableOwner(t *testing.T) {
created, err := sm.AddProfile("owned", &id)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), id)
got, err := sm.ProfileByID(created.ID)
require.NoError(t, err)
require.Len(t, got.Owners, 1, "the profile records the identity it was created for")
assert.True(t, got.Owners[0].Matches(id),
@@ -59,8 +84,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
created, err := sm.AddProfile("work", nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
got := matchOne(t, sm, created.ID.String())
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, "work", got.Name)
})
@@ -72,8 +96,7 @@ func TestServiceProfile_IDPrefix(t *testing.T) {
require.NoError(t, err)
prefix := created.ID[:4]
got, err := sm.ResolveProfile(prefix.String(), userID)
require.NoError(t, err)
got := matchOne(t, sm, prefix.String())
assert.Equal(t, created.ID, got.ID)
})
}
@@ -91,11 +114,12 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
}
_, err = sm.ResolveProfile("abcd", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
assert.Len(t, amb.Candidates, 2)
// Deciding between the two belongs to whoever knows who is asking, so
// the matcher hands both back rather than refusing.
match, err := sm.MatchProfiles("abcd")
require.NoError(t, err)
assert.Equal(t, AmbiguityKindIDPrefix, match.Kind)
assert.Len(t, match.Profiles, 2)
})
}
@@ -104,8 +128,7 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
_, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", userID)
require.NoError(t, err)
got := matchOne(t, sm, "work")
assert.Equal(t, "work", got.Name)
})
}
@@ -117,25 +140,23 @@ func TestServiceProfile_AmbiguousName(t *testing.T) {
_, err = sm.AddProfile("work", &userID)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindName, amb.Kind)
assert.Len(t, amb.Candidates, 2)
match, err := sm.MatchProfiles("work")
require.NoError(t, err)
assert.Equal(t, AmbiguityKindName, match.Kind)
assert.Len(t, match.Profiles, 2)
})
}
func TestServiceProfile_NotFound(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.ResolveProfile("nope", userID)
_, err := sm.MatchProfiles("nope")
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}
func TestServiceProfile_DefaultByExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
got, err := sm.ResolveProfile(defaultProfileName, userID)
require.NoError(t, err)
got := matchOne(t, sm, defaultProfileName)
assert.Equal(t, defaultProfileName, got.ID.String())
})
}
@@ -151,8 +172,7 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
path := filepath.Join(configDir, "legacy.json")
require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
got, err := sm.ResolveProfile("legacy", userID)
require.NoError(t, err)
got := matchOne(t, sm, "legacy")
assert.Equal(t, "legacy", got.ID.String())
// Name falls back to the filename stem when JSON omits it.
assert.Equal(t, "legacy", got.Name)
@@ -187,7 +207,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
err := sm.RemoveProfile("../escape", userID)
err := sm.RemoveProfile("../escape")
assert.Error(t, err)
})
}
@@ -253,7 +273,7 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) {
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
require.NoError(t, sm.RemoveProfile(created.ID, userID))
require.NoError(t, sm.RemoveProfile(created.ID))
_, err = os.Stat(statePath)
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
})
@@ -318,16 +338,14 @@ func TestListProfiles_UnownedProfilesArePrivilegedOnly(t *testing.T) {
require.NoError(t, err)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
got := claimAndList(t, sm, alice)
assert.NotContains(t, profileIDs(got), defaultProfileName,
"the default profile has no exemption, being claimed is what opens it")
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
"every profile needs an owner before anyone can address it")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
got = claimAndList(t, sm, root)
assert.Contains(t, profileIDs(got), defaultProfileName,
"root still reaches both, which is how an unowned profile gets assigned")
assert.Contains(t, profileIDs(got), unowned.ID.String())
@@ -392,15 +410,13 @@ func TestListProfiles_UnownedLegacyProfileIsPrivilegedOnly(t *testing.T) {
stubLegacyDir(t, "bob")
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(bob)
require.NoError(t, err)
got := claimAndList(t, sm, bob)
assert.NotContains(t, profileIDs(got), "work",
"a profile sitting in someone else's directory is not free to take")
assert.Empty(t, readOwners(t, path), "and it is not claimed on the way past")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
got = claimAndList(t, sm, root)
assert.Contains(t, profileIDs(got), "work",
"root still reaches it, which is how it gets reassigned")
})
@@ -412,18 +428,16 @@ func TestListProfiles_ClaimsLegacyProfileForItsOwnAccount(t *testing.T) {
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
got := claimAndList(t, sm, alice)
assert.Contains(t, profileIDs(got), "work",
"the claim lands before the listing is filtered, so the gap closes in one call")
"the profile in the caller's own directory is claimed for them and then listed")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
// The claim is on disk now, so it is the owner check and not the
// directory name that keeps the next caller out.
stubLegacyDir(t, "alice")
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})
got, err = sm.ListProfiles(other)
require.NoError(t, err)
got = claimAndList(t, sm, other)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path),
"a second caller does not overwrite a stamped owner")
@@ -439,8 +453,7 @@ func TestClaimLegacyProfile_LeavesTheProfileWhereItIs(t *testing.T) {
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
got := claimAndList(t, sm, alice)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, ID("work"), claimed.ID, "claiming does not re-key the profile")
@@ -463,8 +476,7 @@ func TestClaimLegacyProfile_NamesakeInAnotherDirectoryIsUntouched(t *testing.T)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
got := claimAndList(t, sm, alice)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, filepath.Join(configDir, "alice", "work.json"), claimed.Path,
@@ -496,8 +508,7 @@ func TestClaimLegacyProfile_SkipsOneAlreadyOwnedInTheSameDirectory(t *testing.T)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Equal(t, []string{"uid:9999"}, readOwners(t, taken),
"a profile that already has an owner is not restamped")
@@ -510,9 +521,9 @@ func TestRenameProfile(t *testing.T) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
require.NoError(t, sm.RenameProfile(created.ID, userID, "weekend"))
require.NoError(t, sm.RenameProfile(created.ID, "weekend"))
got, err := sm.ResolveProfile(created.ID.String(), userID)
got, err := sm.ProfileByID(created.ID)
require.NoError(t, err)
assert.Equal(t, "weekend", got.Name, "the new name is on disk")
assert.Equal(t, created.ID, got.ID, "renaming does not re-key the profile")
@@ -520,47 +531,16 @@ func TestRenameProfile(t *testing.T) {
})
}
func TestRenameProfile_NotTheCallersProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
stranger := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
require.Error(t, sm.RenameProfile(created.ID, stranger, "weekend"),
"a profile the caller cannot address is not theirs to rename")
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, "work", got.Name)
})
}
func TestResolveProfile_ClaimsOnTheWayThrough(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "alice")
// Resolution is what switching a profile goes through, so the claim has
// to land here and not only when something lists profiles.
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ResolveProfile("work", alice)
require.NoError(t, err)
assert.Equal(t, path, got.Path)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
})
}
func TestListProfiles_PrivilegedCallerDoesNotClaim(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "root", "work", nil)
stubLegacyDir(t, "root")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err := sm.ListProfiles(root)
require.NoError(t, err)
got := claimAndList(t, sm, root)
assert.Contains(t, profileIDs(got), "work")
assert.Empty(t, readOwners(t, path),
"root reaches every profile anyway, so a listing must not stamp one")
"root reaches every profile anyway, so the claim must not stamp one")
})
}
@@ -598,8 +578,7 @@ func TestClaimLegacyProfile_LeavesAnOwnerItCannotParse(t *testing.T) {
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
got := claimAndList(t, sm, alice)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"group:devs"}, readOwners(t, path),
@@ -803,8 +782,7 @@ func TestListProfiles_ClaimKeepsFieldsThisVersionDoesNotModel(t *testing.T) {
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path), "the claim still lands")
data, err := os.ReadFile(path)
@@ -834,8 +812,7 @@ func TestClaimDefaultProfile_ConsoleUserClaimsIt(t *testing.T) {
stubConsoleUser(t, true)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, DefaultConfigPath),
"the first caller at the console closes the window the default profile is open in")
})
@@ -918,8 +895,7 @@ func TestClaimDefaultProfile_CallerAwayFromTheConsoleDoesNotClaimIt(t *testing.T
stubConsoleUser(t, false)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Empty(t, readOwners(t, DefaultConfigPath),
"a local caller who is not at the console must not take the machine's profile")
})
@@ -931,8 +907,7 @@ func TestClaimDefaultProfile_DisableEnvWithholdsTheClaim(t *testing.T) {
t.Setenv(EnvDisableDefaultProfileClaim, "true")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Empty(t, readOwners(t, DefaultConfigPath),
"the flag withholds the claim even from a caller who would otherwise get it")
})
@@ -944,8 +919,7 @@ func TestClaimDefaultProfile_UnparseableDisableEnvLeavesTheClaimOn(t *testing.T)
t.Setenv(EnvDisableDefaultProfileClaim, "yes please")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimAndList(t, sm, alice)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, DefaultConfigPath),
"a typo must not be what turns a safety mechanism off")
})