diff --git a/client/internal/ipcauth/authz_gate.go b/client/internal/ipcauth/authz_gate.go index f3173c9e9..722aa9a83 100644 --- a/client/internal/ipcauth/authz_gate.go +++ b/client/internal/ipcauth/authz_gate.go @@ -143,7 +143,7 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (cont if presentable := presentableHandleError(handle, handleErr); presentable != nil { return ctx, presentable } - return ctx, denyPolicyLevel(req, policy) + return ctx, denyPolicyLevel(req, policy, target) } for _, rule := range policy.Rules { if err := rule(req); err != nil { diff --git a/client/internal/ipcauth/authz_level.go b/client/internal/ipcauth/authz_level.go index cc7c87173..e0cecb9ce 100644 --- a/client/internal/ipcauth/authz_level.go +++ b/client/internal/ipcauth/authz_level.go @@ -64,17 +64,14 @@ func denyLevel(r Request, want AuthzLevel) error { // denyPolicyLevel refuses a caller at the gate, where the policy is in hand. // -// Requiring privilege is the one denial a caller can act on, so it carries the -// elevated command rather than a bare refusal. A privileged method that declares -// no action keeps the plain message. Rules deny through denyLevel instead: they -// cannot reach the policy table without an initialization cycle, and no rule -// requires privilege. -func denyPolicyLevel(r Request, p MethodPolicy) error { +// A privileged method that declares no action keeps the plain message. Rules +// deny through denyLevel instead: they cannot reach the policy table without an +// initialization cycle, and no rule requires privilege. +func denyPolicyLevel(r Request, p MethodPolicy, target Target) error { switch p.Level { case AuthzLevelPrivileged: if p.Action != "" { - actor, command := RequiredActor(p.Command) - return PrivilegeError(PrivilegeSummary(p.Action, actor), command) + return denyPrivileged(p, target) } case AuthzLevelSessionHolder: @@ -83,11 +80,27 @@ func denyPolicyLevel(r Request, p MethodPolicy) error { if r.Level == AuthzLevelProfileOwner { return SessionHeldError(p.Action) } - return NotOwnerError(p.Action) + return denyOwnership(p.Action, r.Identity, target) case AuthzLevelProfileOwner: - return NotOwnerError(p.Action) + return denyOwnership(p.Action, r.Identity, target) } return denyLevel(r, p.Level) } + +// denyPrivileged refuses a method that needs a privileged caller. +// Claiming a profile is currently the only privileged method +func denyPrivileged(p MethodPolicy, target Target) error { + actor, command := RequiredActor(ClaimCommand(target.Handle)) + return PrivilegeError(PrivilegeSummary(p.Action, actor), command) +} + +// denyOwnership refuses a caller with no standing on the profile. Only a profile +// nobody has claimed is theirs to put right, so only that one carries a command. +func denyOwnership(action string, id Identity, target Target) error { + if target.UnOwned { + return UnownedError(action, target.Handle, consoleLookup(id)) + } + return NotOwnerError(action) +} diff --git a/client/internal/ipcauth/consoleuser.go b/client/internal/ipcauth/consoleuser.go index 982d2c104..1114b4efa 100644 --- a/client/internal/ipcauth/consoleuser.go +++ b/client/internal/ipcauth/consoleuser.go @@ -25,6 +25,10 @@ func IsConsoleUser(id Identity) bool { return guardConsoleLookup(id, isConsoleUser) } +// consoleLookup is a variable so a test can decide whether a caller is at the +// console without the machine running it having a seat of its own. +var consoleLookup = IsConsoleUser + // guardConsoleLookup runs a platform lookup and turns a panic out of it into // "cannot confirm". func guardConsoleLookup(id Identity, lookup func(Identity) bool) (atConsole bool) { diff --git a/client/internal/ipcauth/methods.go b/client/internal/ipcauth/methods.go index 1ad8b5831..bb1a52762 100644 --- a/client/internal/ipcauth/methods.go +++ b/client/internal/ipcauth/methods.go @@ -45,13 +45,8 @@ type MethodPolicy struct { Audit bool TargetsProfile bool - // Action and Command turn a privilege denial into guidance the caller can - // act on. Action reads as the subject of a sentence ("claiming a profile"), - // Command is the same operation run with the privileges it needs. Only read - // when Level is AuthzLevelPrivileged, the one denial a caller can fix by - // running as somebody else. - Action string - Command string + // Action reads as the subject of a refusal message ("claiming a profile"). + Action string } // RequireHolderForFullStatus escalates a StatusRequest that asks for peer detail @@ -133,14 +128,12 @@ var methodPolicies = map[string]MethodPolicy{ servicePath + "TracePacket": {Level: AuthzLevelSessionHolder, Action: "tracing a packet"}, servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting an update"}, - // Root or administrator only. Claiming names an arbitrary principal, so the - // caller asserts who a profile belongs to. Ownership does not enter it. + // Root or administrator only. servicePath + "ClaimProfile": { Level: AuthzLevelPrivileged, TargetsProfile: true, Audit: true, Action: "claiming a profile", - Command: ElevatedCommand("netbird profile claim "), }, } diff --git a/client/internal/ipcauth/privilege_denial_test.go b/client/internal/ipcauth/privilege_denial_test.go index 9132f8833..6c5fe92c1 100644 --- a/client/internal/ipcauth/privilege_denial_test.go +++ b/client/internal/ipcauth/privilege_denial_test.go @@ -20,27 +20,24 @@ func TestPoliciesDeclareAnAction(t *testing.T) { } } -// A privileged method must also say how to satisfy it, since that is the one -// refusal the caller can act on. -func TestPrivilegedPoliciesDeclareGuidance(t *testing.T) { +// A refusal that names no action cannot say what was refused, and a privileged +// method drops to a bare message without one. +func TestPrivilegedPoliciesDeclareAnAction(t *testing.T) { for method, policy := range methodPolicies { if policy.Level != AuthzLevelPrivileged { continue } - assert.NotEmpty(t, policy.Command, "%s requires privilege but declares no Command", method) + assert.NotEmpty(t, policy.Action, "%s requires privilege but declares no Action", method) } } -// Only privilege is something the caller can run their way out of. The other -// refusals explain and stop there. -func TestOnlyPrivilegedPoliciesDeclareACommand(t *testing.T) { - for method, policy := range methodPolicies { - if policy.Level == AuthzLevelPrivileged { - continue - } - assert.Empty(t, policy.Command, "%s is not privileged but offers a command", method) - } -} +// ownedByAnother is the profile most of these refusals are about: it exists and +// records an owner, that owner is simply not this caller. +var ownedByAnother = Target{Path: "/profiles/someone-else.json"} + +// unownedProfile records no owner, which is what a machine set up with nobody at +// its console leaves behind. +var unownedProfile = Target{Path: "/profiles/default.json", UnOwned: true, Handle: "default"} func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) { req := Request{ @@ -49,7 +46,7 @@ func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) { Method: servicePath + "ClaimProfile", } - err := denyPolicyLevel(req, methodPolicies[servicePath+"ClaimProfile"]) + err := denyPolicyLevel(req, methodPolicies[servicePath+"ClaimProfile"], unownedProfile) require.Error(t, err) st := gstatus.Convert(err) @@ -65,7 +62,8 @@ func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) { assert.Equal(t, ErrorReasonPrivilegeRequired, info.GetReason()) assert.Equal(t, ErrorDomain, info.GetDomain()) assert.NotEmpty(t, info.GetMetadata()[ErrorMetaSummary]) - assert.NotEmpty(t, info.GetMetadata()[ErrorMetaCommand]) + assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default", + "the guidance names the profile the request resolved to, not a placeholder") } // A profile that belongs to somebody else is explained, not answered with sudo. @@ -77,7 +75,7 @@ func TestDenyPolicyLevelExplainsAProfileOwnedByAnother(t *testing.T) { State: stubState{}, } - info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"SetConfig"])) + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"SetConfig"], ownedByAnother)) assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") @@ -94,7 +92,7 @@ func TestDenyPolicyLevelWithoutGuidanceStaysBare(t *testing.T) { Method: servicePath + "NotARealMethod", } - err := denyPolicyLevel(req, methodPolicyFor(req.Method)) + err := denyPolicyLevel(req, methodPolicyFor(req.Method), ownedByAnother) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, gstatus.Convert(err).Code()) assert.Empty(t, gstatus.Convert(err).Details()) @@ -126,7 +124,7 @@ func TestDenyPolicyLevelExplainsAHeldSession(t *testing.T) { Method: servicePath + "Up", } - info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"])) + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], ownedByAnother)) assert.Equal(t, ErrorReasonSessionHeld, info.GetReason()) summary := info.GetMetadata()[ErrorMetaSummary] @@ -148,7 +146,7 @@ func TestDenyPolicyLevelBelowProfileOwnerBlamesOwnership(t *testing.T) { Method: servicePath + "Up", } - info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"])) + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], ownedByAnother)) assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "connected", @@ -236,3 +234,152 @@ func TestDenialFromFallsBackToTheStatusMessage(t *testing.T) { require.True(t, ok) assert.Equal(t, "refused for reasons", denial.Summary) } + +// A profile nobody has claimed is the headless install: the caller is not being +// kept out of somebody else's profile, they are being told to record an owner. +func TestDenyPolicyLevelOffersTheClaimForAnUnownedProfile(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "Up", + } + + stubConsoleLookup(t, false) + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], unownedProfile)) + assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) + + summary := info.GetMetadata()[ErrorMetaSummary] + assert.Contains(t, summary, "Connecting", "the summary names what was refused") + assert.Contains(t, summary, "no owner on record") + assert.NotContains(t, summary, "another user", + "nobody owns it, so blaming another user would be untrue") + + // Without the sudo prefix, which RequiredActor drops for a daemon that is + // not itself privileged, as this test process is not. + assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default", + "the command names the profile that was refused") +} + +// The same refusal reaches a method that only needs profile owner, so a settings +// read on a fresh headless machine explains itself the same way connecting does. +func TestDenyPolicyLevelOffersTheClaimBelowSessionHolderToo(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "GetConfig", + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"GetConfig"], unownedProfile)) + assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) + assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default") +} + +// A session somebody else holds outranks the profile having no owner: the +// connection is what is in the way, and ending it is the remedy. +func TestDenyPolicyLevelKeepsTheHeldSessionAheadOfOwnership(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelProfileOwner, + Method: servicePath + "Up", + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], unownedProfile)) + assert.Equal(t, ErrorReasonSessionHeld, info.GetReason()) +} + +// The claim command names the profile it is going to act on. +func TestClaimCommandNamesTheProfile(t *testing.T) { + assert.Equal(t, ElevatedCommand("netbird profile claim default"), ClaimCommand("default")) + assert.Equal(t, ElevatedCommand("netbird profile claim "), ClaimCommand(""), + "with no profile to name the caller fills in the placeholder") +} + +// stubConsoleLookup decides whether a caller counts as being at the console, +// without the machine running the test having a seat of its own. +func stubConsoleLookup(t *testing.T, atConsole bool) { + t.Helper() + orig := consoleLookup + consoleLookup = func(Identity) bool { return atConsole } + t.Cleanup(func() { consoleLookup = orig }) +} + +// A caller away from the console is told what normally claims a profile, since +// a machine set up without one is how it goes unclaimed. +func TestUnownedSummaryNamesTheConsoleAwayFromIt(t *testing.T) { + summary := unownedSummary("connecting", false) + + assert.Contains(t, summary, "has no owner on record") + assert.Contains(t, summary, "console") + assert.Contains(t, summary, "An explicit claim of the profile is needed.") +} + +// A caller at the console who still finds no owner got here another way, a +// migration that did not finish among them. +func TestUnownedSummaryStaysQuietAboutTheConsoleAtIt(t *testing.T) { + summary := unownedSummary("connecting", true) + + assert.Contains(t, summary, "has no owner on record") + assert.NotContains(t, summary, "console") + assert.Contains(t, summary, "An explicit claim of the profile is needed.", + "the remedy is the same wherever the caller is sitting") +} + +// The reason and the command do not move with the caller, only the explanation +// of how the profile came to be unowned does. +func TestDenyOwnershipKeepsTheClaimForAConsoleCaller(t *testing.T) { + stubConsoleLookup(t, true) + + info := denialDetail(t, denyOwnership("connecting", KnownForTest(Identity{UID: 1000}), unownedProfile)) + assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) + assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default") + assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "console") +} + +// Two commands put an authorization refusal right, ending the session and +// recording an owner. Whatever a denial hands the caller is one of them. +func TestDenialsOfferOnlyTheTwoRemedies(t *testing.T) { + _, down := RequiredActor(DownCommand()) + _, claim := RequiredActor(ClaimCommand(unownedProfile.Handle)) + + for name, err := range map[string]error{ + "privileged": denyPrivileged(methodPolicies[servicePath+"ClaimProfile"], unownedProfile), + "session held": SessionHeldError("connecting"), + "unowned": UnownedError("connecting", unownedProfile.Handle, false), + "not owner": NotOwnerError("connecting"), + } { + t.Run(name, func(t *testing.T) { + denial, ok := DenialFrom(err) + require.True(t, ok, "every refusal has to be machine readable") + if denial.Command == "" { + return + } + assert.Contains(t, []string{down, claim}, denial.Command, + "a refusal offered a command that is neither remedy") + }) + } +} + +// A sudo prefix already says who has to run the command, so repeating it in the +// summary would be noise. +func TestRemedyNoteStaysQuietBehindSudo(t *testing.T) { + assert.Empty(t, remedyNote("root", "sudo netbird down")) +} + +// Windows has no sudo to prefix and neither does a delegating daemon, so the +// summary is the only place that can name who must run the command. +func TestRemedyNoteNamesTheActorWithoutSudo(t *testing.T) { + assert.Equal(t, " Running this requires administrator privileges.", + remedyNote("administrator privileges", "netbird down")) +} + +// The refusal a user hits when somebody else holds the session has to say what +// running the command it offers takes. +func TestSessionHeldSaysWhatRunningTheCommandTakes(t *testing.T) { + denial, ok := DenialFrom(SessionHeldError("switching profile")) + require.True(t, ok) + + actor, command := RequiredActor(DownCommand()) + require.NotContains(t, command, "sudo ", "this test process runs a delegating daemon") + assert.Contains(t, denial.Summary, "Running this requires "+actor) +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go index 4da69cede..2a5fec76b 100644 --- a/client/internal/ipcauth/privileged.go +++ b/client/internal/ipcauth/privileged.go @@ -36,6 +36,10 @@ const ( // not what the method asked for, so telling the caller to elevate would send // them the wrong way. ErrorReasonNotProfileOwner = "NOT_PROFILE_OWNER" + + // ErrorReasonProfileUnowned identifies a refusal caused by the profile + // recording no owner. It carries the command that records one. + ErrorReasonProfileUnowned = "PROFILE_UNOWNED" ) // The identity of the process evaluating callers, captured once because it cannot @@ -210,7 +214,8 @@ func PrivilegeError(summary, command string) error { // SessionHeldError refuses an operation because another user has the machine // connected. func SessionHeldError(action string) error { - return denialError(ErrorReasonSessionHeld, sessionHeldSummary(action), ElevatedCommand("netbird down")) + actor, command := RequiredActor(DownCommand()) + return denialError(ErrorReasonSessionHeld, sessionHeldSummary(action)+remedyNote(actor, command), command) } // NotOwnerError refuses an operation because the profile it addresses belongs to @@ -219,6 +224,36 @@ func NotOwnerError(action string) error { return denialError(ErrorReasonNotProfileOwner, notOwnerSummary(action), "") } +// UnownedError refuses an operation because the profile it addresses has no +// owner on record, and names the command that gives it one. atConsole is whether +// the caller sits at one of this machine's consoles. +func UnownedError(action, handle string, atConsole bool) error { + actor, command := RequiredActor(ClaimCommand(handle)) + return denialError(ErrorReasonProfileUnowned, unownedSummary(action, atConsole)+remedyNote(actor, command), command) +} + +// remedyNote names who has to run the command a refusal offers. +func remedyNote(actor, command string) string { + if strings.HasPrefix(command, "sudo ") { + return "" + } + return " Running this requires " + actor + "." +} + +// DownCommand renders the elevated command that ends the live session. +func DownCommand() string { + return ElevatedCommand("netbird down") +} + +// ClaimCommand renders the elevated command that records an owner for a profile. +// With no profile to name it keeps the placeholder for the caller to fill in. +func ClaimCommand(handle string) string { + if handle == "" { + handle = "" + } + return ElevatedCommand("netbird profile claim " + handle) +} + // sessionHeldSummary says whose the connection is and why that settles it. func sessionHeldSummary(action string) string { return refusedSubject(action) + " refused while another user has this machine connected. " + @@ -233,6 +268,19 @@ func notOwnerSummary(action string) string { "so use one of your own or ask an administrator to hand this one over." } +// unownedSummary says the profile belongs to nobody yet and what changes that. +// Only a caller away from the console is told about it, since a profile left +// unclaimed while somebody is at one got that way for another reason. +func unownedSummary(action string, atConsole bool) string { + cause := "" + if !atConsole { + cause = "A profile is claimed by the user who sets it up at this machine's console (in front of the machine), " + + "and nobody has done that here. " + } + return refusedSubject(action) + " refused because the profile it addresses has no owner on record. " + + cause + "An explicit claim of the profile is needed." +} + // refusedSubject opens a refusal with what was refused, falling back to the // command itself for a method that names no action. func refusedSubject(action string) string { @@ -266,9 +314,7 @@ func denialError(reason, summary, command string) error { } // RequiredActor names who may perform the operation and adjusts the command to -// match. A daemon that is not itself privileged delegates to its own identity, so -// telling that host's user to become root is wrong twice over: root is not what the -// daemon checks for, and a rootless container has neither root nor sudo. +// match. func RequiredActor(command string) (string, string) { self, delegates := SelfDelegatesTo() if !delegates { diff --git a/client/internal/ipcauth/target.go b/client/internal/ipcauth/target.go index 5c0075a4a..f50e3e6f0 100644 --- a/client/internal/ipcauth/target.go +++ b/client/internal/ipcauth/target.go @@ -6,6 +6,11 @@ import "context" type Target struct { Path string Owned bool + // UnOwned reports that the resolved profile records no owner. + UnOwned bool + // Handle is the resolved profile's ID, which a refusal names in the command + // it hands the caller. A request that named nothing still has one. + Handle string } type targetKey struct{} diff --git a/client/server/server.go b/client/server/server.go index 58506ef45..71776e69e 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -2932,7 +2932,7 @@ func targetFromMatch(id ipcauth.Identity, handle string, match profilemanager.Ha switch { case len(owned) == 1: - return ipcauth.Target{Path: owned[0].Path, Owned: true}, nil + return targetForProfile(owned[0], true), nil case len(owned) > 1: return ipcauth.Target{}, gstatus.Errorf(codes.InvalidArgument, "%v", &profilemanager.ErrAmbiguousHandle{ @@ -2943,13 +2943,25 @@ func targetFromMatch(id ipcauth.Identity, handle string, match profilemanager.Ha case len(match.Profiles) > 0: // The handle names a profile that is real but not the caller's. - return ipcauth.Target{Path: match.Profiles[0].Path}, nil + return targetForProfile(match.Profiles[0], false), nil default: return ipcauth.Target{}, gstatus.Errorf(codes.NotFound, "profile %q not found", handle) } } +// targetForProfile describes a resolved profile for the gate. UnOwned comes +// off the profile rather than from owned, since a privileged caller may address +// a profile nobody has claimed. +func targetForProfile(p profilemanager.Profile, owned bool) ipcauth.Target { + return ipcauth.Target{ + Path: p.Path, + Owned: owned, + UnOwned: len(p.Owners) == 0, + Handle: p.ID.String(), + } +} + // matchHandleError renders a failed match as something the caller can act on. func matchHandleError(handle string, err error) error { if errors.Is(err, profilemanager.ErrProfileNotFound) { diff --git a/client/server/server_resolvetarget_test.go b/client/server/server_resolvetarget_test.go index deb334d06..75687bd22 100644 --- a/client/server/server_resolvetarget_test.go +++ b/client/server/server_resolvetarget_test.go @@ -137,6 +137,28 @@ func TestResolveTarget_AnotherUsersProfileIsNotOwned(t *testing.T) { target, err := s.ResolveTarget(foreignIdentity(), activeProfile) require.NoError(t, err, "the profile is there, so nothing about the handle is wrong") require.False(t, target.Owned, "a profile the caller does not own is not theirs to act on") + require.False(t, target.UnOwned, "it has an owner, just not this caller") +} + +// A profile nobody has claimed resolves like any other and reports itself +// unowned, so the refusal can point at the claim. +func TestResolveTarget_UnownedProfileResolvesAsUnOwned(t *testing.T) { + s, _, _, _, _ := setupServerWithProfile(t) + + // Not the default profile, whose own claim path would stamp an owner on it + // the moment a caller at a console resolved it. + unowned := "unowned-profile" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, unowned+".json"), + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + target, err := s.ResolveTarget(unprivilegedIdentity(), unowned) + require.NoError(t, err, "the profile is there, so nothing about the handle is wrong") + require.False(t, target.Owned, "an unclaimed profile is nobody's to act on") + require.True(t, target.UnOwned, "nothing was ever stamped on it") + require.Equal(t, unowned, target.Handle, "the refusal names this in the claim command") } // Only a handle matching two of the caller's own profiles is genuinely diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index dbaa4b7a3..6dbd7c8db 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Dieses Profil gehört einem anderen Benutzer." + }, + "error.profile_unowned": { + "message": "Dieses Profil hat noch keinen Besitzer." } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 9da9b8724..b6e3edea6 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1870,5 +1870,9 @@ "error.not_profile_owner": { "message": "This profile belongs to another user.", "description": "Short headline when the daemon refuses an action because the profile it addresses is owned by a different user account." + }, + "error.profile_unowned": { + "message": "This profile has no owner yet.", + "description": "Short headline when the daemon refuses an action because the profile it addresses has no owner recorded yet. The daemon's own sentence is shown as the detail, followed by a copyable command that records an owner." } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 23037b1b1..fc69c78b8 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Este perfil pertenece a otro usuario." + }, + "error.profile_unowned": { + "message": "Este perfil aún no tiene propietario." } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 8341f9094..7da22b95e 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Ce profil appartient à un autre utilisateur." + }, + "error.profile_unowned": { + "message": "Ce profil n'a pas encore de propriétaire." } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 2be54bb27..22ba7d92b 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Ez a profil egy másik felhasználóé." + }, + "error.profile_unowned": { + "message": "Ennek a profilnak még nincs tulajdonosa." } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 436ba8bb9..c1296a3fb 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Questo profilo appartiene a un altro utente." + }, + "error.profile_unowned": { + "message": "Questo profilo non ha ancora un proprietario." } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 9e9139d83..46358cd23 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "このプロファイルは別のユーザーのものです。" + }, + "error.profile_unowned": { + "message": "このプロファイルにはまだ所有者がいません。" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index d4f79ec2d..7dfa5fb71 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Este perfil pertence a outro usuário." + }, + "error.profile_unowned": { + "message": "Este perfil ainda não tem proprietário." } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index afa3e36fb..3ac9812c9 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "Этот профиль принадлежит другому пользователю." + }, + "error.profile_unowned": { + "message": "У этого профиля пока нет владельца." } } diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index 266570e73..fca8664e0 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1402,5 +1402,8 @@ }, "error.not_profile_owner": { "message": "Цей профіль належить іншому користувачеві." + }, + "error.profile_unowned": { + "message": "Цей профіль ще не має власника." } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 68fea8eb9..ef98f1220 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1404,5 +1404,8 @@ }, "error.not_profile_owner": { "message": "此配置文件属于其他用户。" + }, + "error.profile_unowned": { + "message": "此配置文件尚未设置所有者。" } } diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0d88ff53e..ea19395e9 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -26,6 +26,8 @@ func denialCode(reason string) (string, bool) { return "session_held", true case ipcauth.ErrorReasonNotProfileOwner: return "not_profile_owner", true + case ipcauth.ErrorReasonProfileUnowned: + return "profile_unowned", true default: return "permission_denied", false } diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index ab86e08df..93cc4e6f0 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -70,6 +70,7 @@ func TestClassifyMapsEveryDaemonReason(t *testing.T) { {"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true}, {"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true}, {"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false}, + {"unowned", ipcauth.UnownedError("connecting", "default", false), "profile_unowned", true}, } { t.Run(tc.name, func(t *testing.T) { got := c.classify(tc.err) @@ -116,6 +117,12 @@ func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) { "This profile belongs to another user.", "Reading the profile configuration is refused because the profile it addresses belongs to another user.", }, + { + "unowned", + ipcauth.UnownedError("connecting", "default", false), + "This profile has no owner yet.", + "Connecting is refused because the profile it addresses has no owner on record.", + }, } { t.Run(tc.name, func(t *testing.T) { got := c.classify(tc.err)