-
Notifications
You must be signed in to change notification settings - Fork 360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
8224 report required permissions when authorization fails #8314
Changes from 15 commits
f1c6393
88175ae
2242218
b1f9e3a
11aa10a
ee2986a
d04f675
e883b3c
5da4f66
54dc6aa
743f818
4a054c6
803d4a3
758d910
7b304cc
7992c4b
060083f
2c50f4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import ( | |
"context" | ||
"net/http" | ||
"slices" | ||
"strings" | ||
"testing" | ||
"time" | ||
|
||
|
@@ -234,7 +235,7 @@ func TestCreateRepo_Unauthorized(t *testing.T) { | |
}) | ||
require.NoError(t, err) | ||
require.NotNil(t, resp.JSON401) | ||
if resp.JSON401.Message != auth.ErrInsufficientPermissions.Error() { | ||
if !strings.Contains(resp.JSON401.Message, auth.ErrInsufficientPermissions.Error()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we test here (and below) that we got an expected value? Obviously doing so makes sense only after we finalize the message contents. So it is fine to open an issue to improve these checks in this test file. |
||
t.Fatalf("expected error message %q, got %q", auth.ErrInsufficientPermissions.Error(), resp.JSON401.Message) | ||
} | ||
} | ||
|
@@ -255,15 +256,15 @@ func TestRepoMetadata_Unauthorized(t *testing.T) { | |
}) | ||
require.NoError(t, err) | ||
require.NotNil(t, resp.JSON401) | ||
if resp.JSON401.Message != auth.ErrInsufficientPermissions.Error() { | ||
if !strings.Contains(resp.JSON401.Message, auth.ErrInsufficientPermissions.Error()) { | ||
t.Errorf("expected error message %q, got %q", auth.ErrInsufficientPermissions.Error(), resp.JSON401.Message) | ||
} | ||
}) | ||
t.Run("delete", func(t *testing.T) { | ||
resp, err := clt.DeleteRepositoryMetadataWithResponse(ctx, repo, apigen.DeleteRepositoryMetadataJSONRequestBody{Keys: []string{"foo"}}) | ||
require.NoError(t, err) | ||
require.NotNil(t, resp.JSON401) | ||
if resp.JSON401.Message != auth.ErrInsufficientPermissions.Error() { | ||
if !strings.Contains(resp.JSON401.Message, auth.ErrInsufficientPermissions.Error()) { | ||
t.Errorf("expected error message %q, got %q", auth.ErrInsufficientPermissions.Error(), resp.JSON401.Message) | ||
} | ||
}) | ||
|
@@ -272,7 +273,7 @@ func TestRepoMetadata_Unauthorized(t *testing.T) { | |
resp, err := clt.GetRepositoryMetadataWithResponse(ctx, repo) | ||
require.NoError(t, err) | ||
require.NotNil(t, resp.JSON401) | ||
if resp.JSON401.Message != auth.ErrInsufficientPermissions.Error() { | ||
if !strings.Contains(resp.JSON401.Message, auth.ErrInsufficientPermissions.Error()) { | ||
t.Errorf("expected error message %q, got %q", auth.ErrInsufficientPermissions.Error(), resp.JSON401.Message) | ||
} | ||
}) | ||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -34,11 +34,13 @@ import ( | |||||||
"github.com/treeverse/lakefs/pkg/api/apigen" | ||||||||
"github.com/treeverse/lakefs/pkg/api/apiutil" | ||||||||
"github.com/treeverse/lakefs/pkg/auth" | ||||||||
"github.com/treeverse/lakefs/pkg/auth/model" | ||||||||
"github.com/treeverse/lakefs/pkg/block" | ||||||||
"github.com/treeverse/lakefs/pkg/catalog" | ||||||||
"github.com/treeverse/lakefs/pkg/config" | ||||||||
"github.com/treeverse/lakefs/pkg/graveler" | ||||||||
"github.com/treeverse/lakefs/pkg/httputil" | ||||||||
"github.com/treeverse/lakefs/pkg/permissions" | ||||||||
"github.com/treeverse/lakefs/pkg/stats" | ||||||||
"github.com/treeverse/lakefs/pkg/testutil" | ||||||||
"github.com/treeverse/lakefs/pkg/upload" | ||||||||
|
@@ -5355,6 +5357,155 @@ func TestController_CreateCommitRecord(t *testing.T) { | |||||||
}) | ||||||||
} | ||||||||
|
||||||||
func TestCheckPermissions_UnpermittedRequests(t *testing.T) { | ||||||||
ctx := context.Background() | ||||||||
testCases := []struct { | ||||||||
name string | ||||||||
node permissions.Node | ||||||||
username string | ||||||||
policies []*model.Policy | ||||||||
expected string | ||||||||
}{ | ||||||||
{ | ||||||||
name: "deny single action", | ||||||||
node: permissions.Node{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:DeleteRepository", | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
}, | ||||||||
}, | ||||||||
username: "user1", | ||||||||
policies: []*model.Policy{ | ||||||||
{ | ||||||||
Statement: []model.Statement{ | ||||||||
{ | ||||||||
Action: []string{"fs:DeleteRepository"}, | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
Effect: model.StatementEffectDeny, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
expected: "denied permission to fs:DeleteRepository", | ||||||||
}, ///////////////////////////////////////////////////////////////// | ||||||||
{ | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please avoid adding filler comments. They are pleasant for different people at different widths, they are never consistent, and they end up not appearing everywhere.
Suggested change
|
||||||||
name: "deny multiple actions, one concerning the request", | ||||||||
node: permissions.Node{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:DeleteRepository", | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
}, | ||||||||
}, | ||||||||
username: "user1", | ||||||||
policies: []*model.Policy{ | ||||||||
{ | ||||||||
Statement: []model.Statement{ | ||||||||
{ | ||||||||
Action: []string{"fs:DeleteRepository", "fs:CreateRepository"}, | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
Effect: model.StatementEffectDeny, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
expected: "denied permission to fs:DeleteRepository", | ||||||||
}, ///////////////////////////////////////////////////////////////// | ||||||||
{ | ||||||||
name: "neutral action", | ||||||||
node: permissions.Node{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:ReadRepository", | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
}, | ||||||||
}, | ||||||||
username: "user1", | ||||||||
policies: []*model.Policy{ | ||||||||
{ | ||||||||
Statement: []model.Statement{ | ||||||||
{ | ||||||||
Action: []string{"fs:DeleteRepository"}, | ||||||||
Resource: "arn:lakefs:fs:::repository/repo1", | ||||||||
Effect: model.StatementEffectDeny, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
expected: "missing permission to fs:ReadRepository", | ||||||||
}, ///////////////////////////////////////////////////////////////// | ||||||||
{ | ||||||||
name: "nodeAnd no policy, returns first missing one", | ||||||||
node: permissions.Node{ | ||||||||
Type: permissions.NodeTypeAnd, | ||||||||
Nodes: []permissions.Node{ | ||||||||
{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:CreateRepository", | ||||||||
Resource: "*", | ||||||||
}, | ||||||||
}, | ||||||||
{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:AttachStorageNamespace", | ||||||||
Resource: "*", | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
username: "user1", | ||||||||
expected: "missing permission to fs:CreateRepository", | ||||||||
}, | ||||||||
{ | ||||||||
name: "nodeAnd one policy, returns first missing policy", | ||||||||
node: permissions.Node{ | ||||||||
Type: permissions.NodeTypeAnd, | ||||||||
Nodes: []permissions.Node{ | ||||||||
{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:CreateRepository", | ||||||||
Resource: "*", | ||||||||
}, | ||||||||
}, | ||||||||
{ | ||||||||
Type: permissions.NodeTypeNode, | ||||||||
Permission: permissions.Permission{ | ||||||||
Action: "fs:AttachStorageNamespace", | ||||||||
Resource: "*", | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
username: "user1", | ||||||||
policies: []*model.Policy{ | ||||||||
{ | ||||||||
Statement: []model.Statement{ | ||||||||
{ | ||||||||
Action: []string{"fs:CreateRepository"}, | ||||||||
Resource: "*", | ||||||||
Effect: model.StatementEffectAllow, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
expected: "missing permission to fs:AttachStorageNamespace", | ||||||||
}, | ||||||||
} | ||||||||
for _, tc := range testCases { | ||||||||
t.Run(tc.name, func(t *testing.T) { | ||||||||
perm := &auth.MissingPermissions{} | ||||||||
result := auth.CheckPermissions(ctx, tc.node, tc.username, tc.policies, perm) | ||||||||
fmt.Println("expected:\n" + tc.expected) | ||||||||
fmt.Println("got:\n" + perm.String()) | ||||||||
fmt.Println(result) | ||||||||
require.Equal(t, tc.expected, perm.String()) | ||||||||
}) | ||||||||
} | ||||||||
} | ||||||||
func TestController_CreatePullRequest(t *testing.T) { | ||||||||
clt, deps := setupClientWithAdmin(t) | ||||||||
ctx := context.Background() | ||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -44,12 +44,20 @@ type AuthorizationResponse struct { | |||||||||||
Error error | ||||||||||||
} | ||||||||||||
|
||||||||||||
type MissingPermissions struct { | ||||||||||||
// Denied is a list of actions the user was denied for the attempt. | ||||||||||||
Denied []string | ||||||||||||
// Unauthorized is a list of actions the user did not have for the attempt. | ||||||||||||
Unauthorized []string | ||||||||||||
} | ||||||||||||
|
||||||||||||
// CheckResult - the final result for the authorization is accepted only if it's CheckAllow | ||||||||||||
type CheckResult int | ||||||||||||
|
||||||||||||
const ( | ||||||||||||
InvalidUserID = "" | ||||||||||||
MaxPage = 1000 | ||||||||||||
UserNotAllowed = "not allowed" | ||||||||||||
InvalidUserID = "" | ||||||||||||
MaxPage = 1000 | ||||||||||||
// CheckAllow Permission allowed | ||||||||||||
CheckAllow CheckResult = iota | ||||||||||||
// CheckNeutral Permission neither allowed nor denied | ||||||||||||
|
@@ -918,13 +926,13 @@ func (a *APIAuthService) Authorize(ctx context.Context, req *AuthorizationReques | |||||||||||
if err != nil { | ||||||||||||
return nil, err | ||||||||||||
} | ||||||||||||
|
||||||||||||
allowed := CheckPermissions(ctx, req.RequiredPermissions, req.Username, policies) | ||||||||||||
permAudit := &MissingPermissions{} | ||||||||||||
allowed := CheckPermissions(ctx, req.RequiredPermissions, req.Username, policies, permAudit) | ||||||||||||
|
||||||||||||
if allowed != CheckAllow { | ||||||||||||
return &AuthorizationResponse{ | ||||||||||||
Allowed: false, | ||||||||||||
Error: ErrInsufficientPermissions, | ||||||||||||
Error: fmt.Errorf("%w: %s", ErrInsufficientPermissions, permAudit.String()), | ||||||||||||
}, nil | ||||||||||||
} | ||||||||||||
|
||||||||||||
|
@@ -1147,10 +1155,21 @@ func NewAPIAuthServiceWithClient(client ClientWithResponsesInterface, externalPr | |||||||||||
}, nil | ||||||||||||
} | ||||||||||||
|
||||||||||||
func CheckPermissions(ctx context.Context, node permissions.Node, username string, policies []*model.Policy) CheckResult { | ||||||||||||
func (n *MissingPermissions) String() string { | ||||||||||||
if len(n.Denied) != 0 { | ||||||||||||
return fmt.Sprintf("denied permission to %s", strings.Join(n.Denied, ",")) | ||||||||||||
} | ||||||||||||
if len(n.Unauthorized) != 0 { | ||||||||||||
return fmt.Sprintf("missing permission to %s", strings.Join(n.Unauthorized, ",")) | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l.935 will cause a message that says
Suggested change
An added benefit is that it uses the same word "allow" from the policy language. |
||||||||||||
} | ||||||||||||
return UserNotAllowed | ||||||||||||
} | ||||||||||||
|
||||||||||||
func CheckPermissions(ctx context.Context, node permissions.Node, username string, policies []*model.Policy, permAudit *MissingPermissions) CheckResult { | ||||||||||||
allowed := CheckNeutral | ||||||||||||
switch node.Type { | ||||||||||||
case permissions.NodeTypeNode: | ||||||||||||
hasPermission := false | ||||||||||||
// check whether the permission is allowed, denied or natural (not allowed and not denied) | ||||||||||||
for _, policy := range policies { | ||||||||||||
for _, stmt := range policy.Statement { | ||||||||||||
|
@@ -1165,21 +1184,26 @@ func CheckPermissions(ctx context.Context, node permissions.Node, username strin | |||||||||||
|
||||||||||||
if stmt.Effect == model.StatementEffectDeny { | ||||||||||||
// this is a "Deny" and it takes precedence | ||||||||||||
permAudit.Denied = append(permAudit.Denied, action) | ||||||||||||
return CheckDeny | ||||||||||||
} else { | ||||||||||||
hasPermission = true | ||||||||||||
allowed = CheckAllow | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
is closer to the previous version. We prefer to use an "early return" style: errors return immediately - like l. 1188 does; successes keep going with no "else" and no need to indent. |
||||||||||||
} | ||||||||||||
|
||||||||||||
allowed = CheckAllow | ||||||||||||
} | ||||||||||||
} | ||||||||||||
} | ||||||||||||
if !hasPermission { | ||||||||||||
permAudit.Unauthorized = append(permAudit.Unauthorized, node.Permission.Action) | ||||||||||||
} | ||||||||||||
|
||||||||||||
case permissions.NodeTypeOr: | ||||||||||||
// returns: | ||||||||||||
// Allowed - at least one of the permissions is allowed and no one is denied | ||||||||||||
// Denied - one of the permissions is Deny | ||||||||||||
// Natural - otherwise | ||||||||||||
for _, node := range node.Nodes { | ||||||||||||
result := CheckPermissions(ctx, node, username, policies) | ||||||||||||
result := CheckPermissions(ctx, node, username, policies, permAudit) | ||||||||||||
if result == CheckDeny { | ||||||||||||
return CheckDeny | ||||||||||||
} | ||||||||||||
|
@@ -1194,7 +1218,7 @@ func CheckPermissions(ctx context.Context, node permissions.Node, username strin | |||||||||||
// Denied - one of the permissions is Deny | ||||||||||||
// Natural - otherwise | ||||||||||||
for _, node := range node.Nodes { | ||||||||||||
result := CheckPermissions(ctx, node, username, policies) | ||||||||||||
result := CheckPermissions(ctx, node, username, policies, permAudit) | ||||||||||||
if result == CheckNeutral || result == CheckDeny { | ||||||||||||
return result | ||||||||||||
} | ||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Going back to prev question - what are you doing with
perm
afterauth.CheckPermissions
? I don't see it being used.