Repository navigation
Expand file tree
/
Copy pathengine_session_roles_test.go
More file actions
245 lines (198 loc) · 8.25 KB
/
Copy pathengine_session_roles_test.go
File metadata and controls
245 lines (198 loc) · 8.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
package authsome
import (
"context"
"errors"
"testing"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/principal"
"github.com/xraph/authsome/session"
"github.com/xraph/authsome/store"
)
// stubSessionStore records what actually reached persistence.
//
// It embeds a nil store.Store so only the two methods the decorator overrides
// need implementing; anything else this test provoked would panic loudly
// rather than silently pass through, which is the behaviour we want from a
// stub standing in for a 200-method interface.
type stubSessionStore struct {
store.Store
created *session.Session
rotated *session.Session
rotateOK bool
rotateNo error
}
func (s *stubSessionStore) CreateSession(_ context.Context, sess *session.Session) error {
s.created = sess
return nil
}
func (s *stubSessionStore) RotateSession(
_ context.Context, sess *session.Session, _ string,
) (bool, error) {
s.rotated = sess
return s.rotateOK, s.rotateNo
}
func testSession() *session.Session {
return &session.Session{
ID: id.NewSessionID(),
AppID: id.NewAppID(),
UserID: id.NewUserID(),
}
}
func stampingStore(t *testing.T, stamp roleStamper) (store.Store, *stubSessionStore) {
t.Helper()
inner := &stubSessionStore{rotateOK: true}
return newRoleStampingStore(inner, stamp, nil), inner
}
func TestCreateSessionStampsRoles(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
return []string{"admin", "owner"}, nil
})
sess := testSession()
if err := s.CreateSession(context.Background(), sess); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if got := inner.created.Roles; len(got) != 2 || got[0] != "admin" || got[1] != "owner" {
t.Errorf("persisted roles = %v, want [admin owner]", got)
}
}
// TestCreateSessionFailsWhenRolesCannotBeResolved pins the policy chosen in
// CreateSession: a sign-in that cannot resolve roles fails rather than
// producing a session with none.
//
// The stamp is persisted, so the alternative is not a momentary degradation.
// It is a session that authenticates for its whole lifetime and can reach no
// route declaring a role. The assertion that nothing was persisted is the
// important half: failing after the insert would leave exactly the session
// this policy exists to prevent.
func TestCreateSessionFailsWhenRolesCannotBeResolved(t *testing.T) {
wantErr := errors.New("rbac unavailable")
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
return nil, wantErr
})
err := s.CreateSession(context.Background(), testSession())
if !errors.Is(err, wantErr) {
t.Fatalf("CreateSession error = %v, want it to wrap %v", err, wantErr)
}
if inner.created != nil {
t.Error("a session was persisted despite the role lookup failing")
}
}
// TestCreateSessionLeavesCallerSuppliedRoles covers the escape hatch: a caller
// that resolved roles itself keeps control of what it wrote.
func TestCreateSessionLeavesCallerSuppliedRoles(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
t.Error("stamper ran for a session that already carried roles")
return []string{"resolved"}, nil
})
sess := testSession()
sess.Roles = []string{"impersonator"}
if err := s.CreateSession(context.Background(), sess); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if got := inner.created.Roles; len(got) != 1 || got[0] != "impersonator" {
t.Errorf("persisted roles = %v, want the caller's [impersonator]", got)
}
}
// TestCreateSessionSkipsServiceAccounts guards the one principal kind with no
// roles to look up: its UserID is the zero value, so a lookup would be both
// meaningless and, under the policy above, fatal to every service-account
// sign-in.
func TestCreateSessionSkipsServiceAccounts(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
t.Error("stamper ran for a service-account session")
return nil, errors.New("should not be called")
})
sess := testSession()
sess.PrincipalKind = principal.KindService
sess.UserID = id.UserID{}
if err := s.CreateSession(context.Background(), sess); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if len(inner.created.Roles) != 0 {
t.Errorf("service-account session carried roles: %v", inner.created.Roles)
}
}
// TestCreateSessionSkipsAgents guards the other non-human principal kind
// that still carries a delegating human's UserID: agentauth.Authorize
// enforces the grant's scope intersected with that human's own permission,
// and stamping the human's full role set onto the session would let a
// role-gated route's requirement be satisfied straight off sess.Roles,
// bypassing the scope half of that intersection without agentauth.Authorize
// ever being consulted.
func TestCreateSessionSkipsAgents(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
t.Error("stamper ran for an agent session")
return nil, errors.New("should not be called")
})
sess := testSession()
sess.PrincipalKind = session.PrincipalKindAgent
sess.AgentID = id.NewAgentID()
sess.GrantID = id.NewAgentGrantID()
if err := s.CreateSession(context.Background(), sess); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if len(inner.created.Roles) != 0 {
t.Errorf("agent session carried roles: %v", inner.created.Roles)
}
}
// TestRotateSessionReStampsRoles is why rotation is decorated at all: without
// it, a role granted after sign-in waits for the user to sign out and back in.
func TestRotateSessionReStampsRoles(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
return []string{"admin", "auditor"}, nil
})
sess := testSession()
sess.Roles = []string{"admin"} // what it was issued with
if _, err := s.RotateSession(context.Background(), sess, "old-token"); err != nil {
t.Fatalf("RotateSession: %v", err)
}
if got := inner.rotated.Roles; len(got) != 2 || got[1] != "auditor" {
t.Errorf("rotated roles = %v, want the newly granted [admin auditor]", got)
}
}
// TestRotateSessionKeepsRolesWhenRefreshFails pins the other half of the
// policy pair. Rotation has a good fallback that CreateSession does not: roles
// that resolved successfully at issue time. Refusing the rotation would sign
// the user out over a transient lookup failure, so it continues with those.
func TestRotateSessionKeepsRolesWhenRefreshFails(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
return nil, errors.New("rbac unavailable")
})
sess := testSession()
sess.Roles = []string{"admin"}
ok, err := s.RotateSession(context.Background(), sess, "old-token")
if err != nil {
t.Fatalf("RotateSession returned %v, want the rotation to proceed", err)
}
if !ok {
t.Error("rotation reported failure when only the role refresh failed")
}
if got := inner.rotated.Roles; len(got) != 1 || got[0] != "admin" {
t.Errorf("rotated roles = %v, want the previously stamped [admin]", got)
}
}
// TestRotateSessionSkipsAgents is the refresh-time counterpart to
// TestCreateSessionSkipsAgents. Before this fix, an agent session presented
// for refresh (which requires a real, non-empty RefreshToken — the very
// thing plugins/agentauth's C1 fix supplies) would be re-stamped with the
// delegating human's full role set on every rotation, restoring the exact
// role-gated bypass shouldStamp closes at issue time. Engine.Refresh also
// now refuses to rotate an agent-principal session at all (service.go), but
// this decorator is the one place every RotateSession call funnels through
// regardless of what calls it, so the exclusion has to hold here
// independently of that upstream refusal.
func TestRotateSessionSkipsAgents(t *testing.T) {
s, inner := stampingStore(t, func(context.Context, id.AppID, id.UserID) ([]string, error) {
return []string{"admin", "owner"}, nil
})
sess := testSession()
sess.PrincipalKind = session.PrincipalKindAgent
sess.AgentID = id.NewAgentID()
sess.GrantID = id.NewAgentGrantID()
if _, err := s.RotateSession(context.Background(), sess, "old-token"); err != nil {
t.Fatalf("RotateSession: %v", err)
}
if got := inner.rotated.Roles; len(got) != 0 {
t.Errorf("roles after rotate on agent session = %v, want none", got)
}
}