mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-26 08:39:06 +02:00
Simply error surfacing
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <profile>"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <profile>"), 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)
|
||||
}
|
||||
|
||||
@@ -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 = "<profile>"
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
+14
-2
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,5 +1404,8 @@
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "このプロファイルは別のユーザーのものです。"
|
||||
},
|
||||
"error.profile_unowned": {
|
||||
"message": "このプロファイルにはまだ所有者がいません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,5 +1404,8 @@
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Этот профиль принадлежит другому пользователю."
|
||||
},
|
||||
"error.profile_unowned": {
|
||||
"message": "У этого профиля пока нет владельца."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1402,5 +1402,8 @@
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Цей профіль належить іншому користувачеві."
|
||||
},
|
||||
"error.profile_unowned": {
|
||||
"message": "Цей профіль ще не має власника."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,5 +1404,8 @@
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "此配置文件属于其他用户。"
|
||||
},
|
||||
"error.profile_unowned": {
|
||||
"message": "此配置文件尚未设置所有者。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user