Skip to content

Commit 76cf2ea

Browse files
serg4kostiukoxyno-zeta
authored andcommitted
feat(user-isolation): enhance user isolation functionality and error handling
1 parent 2b8cf4e commit 76cf2ea

9 files changed

Lines changed: 556 additions & 225 deletions

docs/feature-guide/user-isolation.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,61 @@ targets:
127127
enabled: true
128128
```
129129
130+
### OIDC configuration
131+
132+
When the auth provider is OIDC, the identifier comes from the
133+
`preferred_username` claim if the IdP emits one, or falls back to
134+
the user's email address otherwise. List in `userIsolationAdmins`
135+
exactly the value the IdP sends — `preferred_username` for users
136+
who have one, or the email address for users who don't.
137+
138+
```yaml
139+
authProviders:
140+
oidc:
141+
keycloak:
142+
clientId: s3-proxy
143+
issuerUrl: https://sso.example.com/auth/realms/internal
144+
callbackPath: /auth/oidc/callback
145+
# ...credentials...
146+
targets:
147+
shared:
148+
bucket:
149+
name: my-bucket
150+
prefix: internal/
151+
# ...credentials...
152+
mount:
153+
path:
154+
- /files/
155+
resources:
156+
- path: /files/*
157+
methods: [GET, PUT, DELETE, HEAD]
158+
provider: keycloak
159+
oidc: {}
160+
actions:
161+
GET:
162+
enabled: true
163+
config:
164+
userIsolation: true
165+
userIsolationAdmins:
166+
# Identifier semantics: this matches preferred_username
167+
# if the IdP emits one, otherwise the email claim.
168+
- storage-admin
169+
- ops@example.com
170+
PUT:
171+
enabled: true
172+
DELETE:
173+
enabled: true
174+
```
175+
176+
In this setup:
177+
178+
- A user with `preferred_username: alice` lands in `internal/alice/`.
179+
- A user with no `preferred_username` claim and `email: bob@example.com`
180+
lands in `internal/bob@example.com/`.
181+
- `storage-admin` (matched by `preferred_username`) and the user with
182+
`email: ops@example.com` (matched when `preferred_username` is empty)
183+
bypass isolation and see the full `internal/` prefix.
184+
130185
### Notes
131186

132187
- Isolation requires an authenticated user. The target must declare

pkg/s3-proxy/bucket/bucket-req-impl.go

Lines changed: 42 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66
"io"
77
"path"
8-
"slices"
98
"strings"
109
"time"
1110

@@ -86,6 +85,26 @@ func (bri *bucketReqImpl) displayPrefix(ctx context.Context) (string, error) {
8685
return bri.targetCfg.Bucket.GetRootPrefix() + seg, nil
8786
}
8887

88+
// respondToUserIsolationError maps an error from generateStartKey or
89+
// displayPrefix to the right HTTP response on resHan. Returns true when
90+
// it wrote a response (caller should return early), false otherwise.
91+
// Centralising this keeps the four request-handling entry points
92+
// (Get/Put/Delete/listing) in lockstep on what errUserIsolationForbidden
93+
// means at the HTTP layer.
94+
func (bri *bucketReqImpl) respondToUserIsolationError(resHan responsehandler.ResponseHandler, err error) bool {
95+
if err == nil {
96+
return false
97+
}
98+
99+
if errors.Is(err, errUserIsolationForbidden) {
100+
resHan.ForbiddenError(bri.LoadFileContent, err)
101+
} else {
102+
resHan.InternalServerError(bri.LoadFileContent, err)
103+
}
104+
105+
return true
106+
}
107+
89108
func (bri *bucketReqImpl) manageKeyRewrite(ctx context.Context, key string) (string, error) {
90109
// Check if key rewrite list exists
91110
if bri.targetCfg.KeyRewriteList != nil {
@@ -201,16 +220,7 @@ func (bri *bucketReqImpl) internalGetOrHead(ctx context.Context, input *GetInput
201220

202221
// Generate start key
203222
key, err := bri.generateStartKey(ctx, input.RequestPath)
204-
// Check error
205-
if err != nil {
206-
if errors.Is(err, errUserIsolationForbidden) {
207-
resHan.ForbiddenError(bri.LoadFileContent, err)
208-
209-
return
210-
}
211-
212-
resHan.InternalServerError(bri.LoadFileContent, err)
213-
223+
if bri.respondToUserIsolationError(resHan, err) {
214224
return
215225
}
216226
// Manage key rewrite
@@ -429,15 +439,7 @@ func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input
429439
// Use displayPrefix so the user-facing Path hides the injected identifier
430440
// for non-admin users under userIsolation.
431441
displayPfx, err := bri.displayPrefix(ctx)
432-
if err != nil {
433-
if errors.Is(err, errUserIsolationForbidden) {
434-
resHan.ForbiddenError(bri.LoadFileContent, err)
435-
436-
return
437-
}
438-
439-
resHan.InternalServerError(bri.LoadFileContent, err)
440-
442+
if bri.respondToUserIsolationError(resHan, err) {
441443
return
442444
}
443445

@@ -457,16 +459,7 @@ func (bri *bucketReqImpl) Put(ctx context.Context, inp *PutInput) {
457459

458460
// Generate start key
459461
key, err := bri.generateStartKey(ctx, inp.RequestPath)
460-
// Check error
461-
if err != nil {
462-
if errors.Is(err, errUserIsolationForbidden) {
463-
resHan.ForbiddenError(bri.LoadFileContent, err)
464-
465-
return
466-
}
467-
468-
resHan.InternalServerError(bri.LoadFileContent, err)
469-
462+
if bri.respondToUserIsolationError(resHan, err) {
470463
return
471464
}
472465
// Add / at the end if not present
@@ -730,16 +723,7 @@ func (bri *bucketReqImpl) Delete(ctx context.Context, requestPath string) {
730723

731724
// Generate start key
732725
key, err := bri.generateStartKey(ctx, requestPath)
733-
// Check error
734-
if err != nil {
735-
if errors.Is(err, errUserIsolationForbidden) {
736-
resHan.ForbiddenError(bri.LoadFileContent, err)
737-
738-
return
739-
}
740-
741-
resHan.InternalServerError(bri.LoadFileContent, err)
742-
726+
if bri.respondToUserIsolationError(resHan, err) {
743727
return
744728
}
745729
// Manage key rewrite
@@ -791,23 +775,35 @@ func (bri *bucketReqImpl) Delete(ctx context.Context, requestPath string) {
791775
)
792776
}
793777

778+
// userIsolationCfg returns the GET-action config that owns the userIsolation
779+
// fields, or nil when the chain is incomplete. Centralising the nil walk
780+
// keeps the two consumers below as one-liners.
781+
func (bri *bucketReqImpl) userIsolationCfg() *config.GetActionConfigConfig {
782+
if bri.targetCfg.Actions == nil || bri.targetCfg.Actions.GET == nil {
783+
return nil
784+
}
785+
786+
return bri.targetCfg.Actions.GET.Config
787+
}
788+
794789
// isUserIsolationEnabled checks if userIsolation is configured on the GET action.
795790
func (bri *bucketReqImpl) isUserIsolationEnabled() bool {
796-
return bri.targetCfg.Actions != nil && bri.targetCfg.Actions.GET != nil &&
797-
bri.targetCfg.Actions.GET.Config != nil &&
798-
bri.targetCfg.Actions.GET.Config.UserIsolation
791+
cfg := bri.userIsolationCfg()
792+
793+
return cfg != nil && cfg.UserIsolation
799794
}
800795

801796
// isUserIsolationAdmin checks if the given user identifier is in the admin
802797
// list. The identifier matches what GenericUser.GetIdentifier() returns
803798
// (username for basic auth, preferred_username or email for OIDC, etc.).
799+
// Lookup is O(1) via the precomputed admin set populated at config validation.
804800
func (bri *bucketReqImpl) isUserIsolationAdmin(identifier string) bool {
805-
if bri.targetCfg.Actions == nil || bri.targetCfg.Actions.GET == nil ||
806-
bri.targetCfg.Actions.GET.Config == nil {
801+
cfg := bri.userIsolationCfg()
802+
if cfg == nil {
807803
return false
808804
}
809805

810-
return slices.Contains(bri.targetCfg.Actions.GET.Config.UserIsolationAdmins, identifier)
806+
return cfg.IsUserIsolationAdmin(identifier)
811807
}
812808

813809
func transformS3Entries(

pkg/s3-proxy/bucket/user-isolation_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88
"time"
99

10+
"emperror.dev/errors"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
"go.uber.org/mock/gomock"
@@ -1482,3 +1483,118 @@ func Test_requestContext_Get_UserIsolation(t *testing.T) {
14821483
})
14831484
}
14841485
}
1486+
1487+
// Test_respondToUserIsolationError covers the error→HTTP-response mapping
1488+
// extracted out of the four request entry points. The contract is:
1489+
//
1490+
// - nil err → no response, returns false (caller continues normally)
1491+
// - errUserIsolationForbidden → ForbiddenError, returns true
1492+
// - any other err → InternalServerError, returns true
1493+
func Test_respondToUserIsolationError(t *testing.T) {
1494+
t.Run("nil error writes nothing and returns false", func(t *testing.T) {
1495+
ctrl := gomock.NewController(t)
1496+
resHan := responsehandlermocks.NewMockResponseHandler(ctrl)
1497+
// No EXPECT() — any call would fail the test.
1498+
1499+
bri := &bucketReqImpl{}
1500+
if bri.respondToUserIsolationError(resHan, nil) {
1501+
t.Fatal("nil error must return false")
1502+
}
1503+
})
1504+
1505+
t.Run("errUserIsolationForbidden maps to ForbiddenError", func(t *testing.T) {
1506+
ctrl := gomock.NewController(t)
1507+
resHan := responsehandlermocks.NewMockResponseHandler(ctrl)
1508+
resHan.EXPECT().
1509+
ForbiddenError(gomock.Any(), errUserIsolationForbidden).
1510+
Times(1)
1511+
1512+
bri := &bucketReqImpl{}
1513+
if !bri.respondToUserIsolationError(resHan, errUserIsolationForbidden) {
1514+
t.Fatal("isolation error must return true")
1515+
}
1516+
})
1517+
1518+
t.Run("wrapped errUserIsolationForbidden also maps to ForbiddenError", func(t *testing.T) {
1519+
ctrl := gomock.NewController(t)
1520+
resHan := responsehandlermocks.NewMockResponseHandler(ctrl)
1521+
wrapped := errors.Wrap(errUserIsolationForbidden, "while building start key")
1522+
resHan.EXPECT().
1523+
ForbiddenError(gomock.Any(), wrapped).
1524+
Times(1)
1525+
1526+
bri := &bucketReqImpl{}
1527+
if !bri.respondToUserIsolationError(resHan, wrapped) {
1528+
t.Fatal("wrapped isolation error must return true")
1529+
}
1530+
})
1531+
1532+
t.Run("other errors map to InternalServerError", func(t *testing.T) {
1533+
ctrl := gomock.NewController(t)
1534+
resHan := responsehandlermocks.NewMockResponseHandler(ctrl)
1535+
other := errors.New("template execution failed")
1536+
resHan.EXPECT().
1537+
InternalServerError(gomock.Any(), other).
1538+
Times(1)
1539+
1540+
bri := &bucketReqImpl{}
1541+
if !bri.respondToUserIsolationError(resHan, other) {
1542+
t.Fatal("non-isolation error must return true")
1543+
}
1544+
})
1545+
}
1546+
1547+
// Test_userIsolationCfg covers the centralised nil walk: every level of the
1548+
// Actions / GET / Config chain that can be missing must produce a clean nil
1549+
// rather than a panic, so the consumers (isUserIsolationEnabled and
1550+
// isUserIsolationAdmin) can be one-liners.
1551+
func Test_userIsolationCfg(t *testing.T) {
1552+
tests := []struct {
1553+
target *config.TargetConfig
1554+
name string
1555+
wantNil bool
1556+
}{
1557+
{
1558+
name: "nil Actions returns nil",
1559+
target: &config.TargetConfig{},
1560+
wantNil: true,
1561+
},
1562+
{
1563+
name: "nil GET returns nil",
1564+
target: &config.TargetConfig{
1565+
Actions: &config.ActionsConfig{},
1566+
},
1567+
wantNil: true,
1568+
},
1569+
{
1570+
name: "nil Config still walks (returns the nil pointer)",
1571+
target: &config.TargetConfig{
1572+
Actions: &config.ActionsConfig{
1573+
GET: &config.GetActionConfig{},
1574+
},
1575+
},
1576+
wantNil: true,
1577+
},
1578+
{
1579+
name: "fully populated returns the config pointer",
1580+
target: &config.TargetConfig{
1581+
Actions: &config.ActionsConfig{
1582+
GET: &config.GetActionConfig{
1583+
Config: &config.GetActionConfigConfig{UserIsolation: true},
1584+
},
1585+
},
1586+
},
1587+
wantNil: false,
1588+
},
1589+
}
1590+
for _, tt := range tests {
1591+
t.Run(tt.name, func(t *testing.T) {
1592+
bri := &bucketReqImpl{targetCfg: tt.target}
1593+
got := bri.userIsolationCfg()
1594+
1595+
if tt.wantNil != (got == nil) {
1596+
t.Fatalf("userIsolationCfg returned %v, wantNil=%v", got, tt.wantNil)
1597+
}
1598+
})
1599+
}
1600+
}

pkg/s3-proxy/config/config.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"regexp"
5+
"slices"
56
"strings"
67
"time"
78

@@ -459,6 +460,44 @@ type GetActionConfigConfig struct {
459460
DisableListing bool `mapstructure:"disableListing" json:"disableListing"`
460461
UserIsolation bool `mapstructure:"userIsolation" json:"userIsolation"`
461462
UserIsolationAdmins []string `mapstructure:"userIsolationAdmins" json:"userIsolationAdmins" validate:"omitempty,dive"`
463+
// userIsolationAdminsSet is a derived O(1) lookup set populated at
464+
// config validation time. It is not part of the input schema and is
465+
// safe for concurrent reads after validation completes.
466+
userIsolationAdminsSet map[string]struct{} `json:"-"`
467+
}
468+
469+
// IsUserIsolationAdmin returns true when identifier is in
470+
// UserIsolationAdmins. Uses the precomputed set when available
471+
// (after validation), otherwise falls back to a linear scan so the
472+
// behaviour is correct even if the config is constructed by hand
473+
// in tests that bypass validation.
474+
func (c *GetActionConfigConfig) IsUserIsolationAdmin(identifier string) bool {
475+
if c == nil {
476+
return false
477+
}
478+
479+
if c.userIsolationAdminsSet != nil {
480+
_, ok := c.userIsolationAdminsSet[identifier]
481+
482+
return ok
483+
}
484+
485+
return slices.Contains(c.UserIsolationAdmins, identifier)
486+
}
487+
488+
// indexUserIsolationAdmins populates the O(1) lookup set from
489+
// UserIsolationAdmins. Idempotent and safe to call repeatedly.
490+
func (c *GetActionConfigConfig) indexUserIsolationAdmins() {
491+
if c == nil {
492+
return
493+
}
494+
495+
set := make(map[string]struct{}, len(c.UserIsolationAdmins))
496+
for _, a := range c.UserIsolationAdmins {
497+
set[a] = struct{}{}
498+
}
499+
500+
c.userIsolationAdminsSet = set
462501
}
463502

464503
// WebhookConfig Webhook configuration.

0 commit comments

Comments
 (0)