Skip to content

Commit e70bcab

Browse files
committed
feat: implement multi-guard support across API methods, middleware, and database schema
1 parent dc34d01 commit e70bcab

9 files changed

Lines changed: 578 additions & 174 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,14 @@ The package utilizes 5 tables:
4040
1. `permissions`: Stores permission names (e.g., `users.list`, `articles.create`).
4141
2. `roles`: Stores role names (e.g., `admin`, `editor`).
4242
3. `role_has_permissions`: Links permissions to roles (which roles can perform which actions).
43-
4. `model_has_roles`: Assigns roles to models (e.g., assigning the `editor` role to a specific user within a specific workspace/team).
44-
5. `model_has_permissions`: Assigns direct permission overrides to models (e.g., giving a specific user `articles.delete` even if their role doesn't allow it).
43+
4. `model_has_roles`: Assigns roles to models (e.g., assigning the `editor` role to a specific user within a specific workspace/team using `team_id`).
44+
5. `model_has_permissions`: Assigns direct permission overrides to models (e.g., giving a specific user `articles.delete` even if their role doesn't allow it, also scoped by `team_id`).
45+
46+
### The `team_id` Architecture
47+
Both `model_has_roles` and `model_has_permissions` contain an optional `team_id UUID` column. This is the foundation for multi-tenant and workspace-scoped authorization.
48+
- If a user is assigned a role with `team_id = NULL`, it is a **global** role.
49+
- If assigned with a specific `team_id`, the user only possesses that role/permission when operating within the context of that specific team.
50+
The `team_id` column is built into the Primary Key of these tables to allow a user to hold different roles across different teams.
4551

4652
### Understanding `guard_name`
4753

gate.go

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,20 @@ func NewGate(db DBTX, cfg *Config) *Gate {
6262
}
6363
}
6464

65+
// resolveGuardName returns the default guard name if the provided one is empty.
66+
func (g *Gate) resolveGuardName(guardName string) string {
67+
if guardName == "" {
68+
return g.cfg.DefaultGuardName
69+
}
70+
return guardName
71+
}
72+
6573
// LoadPolicy fetches all role-permission associations from the database
6674
// and caches them in memory. This is thread-safe and should be run on boot
6775
// or when permissions are updated.
6876
func (g *Gate) LoadPolicy(ctx context.Context) error {
6977
query := fmt.Sprintf(`
70-
SELECT r.name, p.name
78+
SELECT r.guard_name, r.name, p.name
7179
FROM %s rhp
7280
JOIN %s r ON r.id = rhp.role_id
7381
JOIN %s p ON p.id = rhp.permission_id
@@ -83,15 +91,16 @@ func (g *Gate) LoadPolicy(ctx context.Context) error {
8391
newCache := make(map[string]map[string]bool)
8492

8593
for rows.Next() {
86-
var roleName, permissionName string
87-
if err := rows.Scan(&roleName, &permissionName); err != nil {
94+
var guardName, roleName, permissionName string
95+
if err := rows.Scan(&guardName, &roleName, &permissionName); err != nil {
8896
return fmt.Errorf("wpd-gogate: scan role permission row: %w", err)
8997
}
9098

91-
if _, exists := newCache[roleName]; !exists {
92-
newCache[roleName] = make(map[string]bool)
99+
cacheKey := guardName + ":" + roleName
100+
if _, exists := newCache[cacheKey]; !exists {
101+
newCache[cacheKey] = make(map[string]bool)
93102
}
94-
newCache[roleName][permissionName] = true
103+
newCache[cacheKey][permissionName] = true
95104
}
96105

97106
if err := rows.Err(); err != nil {
@@ -108,11 +117,14 @@ func (g *Gate) LoadPolicy(ctx context.Context) error {
108117

109118
// HasRolePermission performs an in-memory O(1) check of whether a role
110119
// is assigned a specific permission.
111-
func (g *Gate) HasRolePermission(roleName, permissionName string) bool {
120+
func (g *Gate) HasRolePermission(guardName, roleName, permissionName string) bool {
121+
guardName = g.resolveGuardName(guardName)
122+
112123
g.mu.RLock()
113124
defer g.mu.RUnlock()
114125

115-
permissions, exists := g.rolePermissions[roleName]
126+
cacheKey := guardName + ":" + roleName
127+
permissions, exists := g.rolePermissions[cacheKey]
116128
if !exists {
117129
return false
118130
}
@@ -123,25 +135,55 @@ func (g *Gate) HasRolePermission(roleName, permissionName string) bool {
123135
// It queries both direct permissions and roles in a single database round-trip (UNION ALL),
124136
// then maps them against the in-memory cache to determine access.
125137
// teamID is optional and can be nil to check global assignments.
126-
func (g *Gate) Check(ctx context.Context, modelType string, modelID any, permissionName string, teamID any) (bool, error) {
127-
query := fmt.Sprintf(`
128-
SELECT 'role' AS type, r.name AS value
129-
FROM %s mhr
130-
JOIN %s r ON r.id = mhr.role_id
131-
WHERE mhr.model_type = $1
132-
AND mhr.model_id = $2
133-
AND mhr.team_id IS NOT DISTINCT FROM $3
134-
UNION ALL
135-
SELECT 'permission' AS type, p.name AS value
136-
FROM %s mhp
137-
JOIN %s p ON p.id = mhp.permission_id
138-
WHERE mhp.model_type = $1
139-
AND mhp.model_id = $2
140-
AND mhp.team_id IS NOT DISTINCT FROM $3
141-
AND p.name = $4
142-
`, g.cfg.ModelHasRolesTable, g.cfg.RolesTable, g.cfg.ModelHasPermissionsTable, g.cfg.PermissionsTable)
143-
144-
rows, err := g.db.QueryContext(ctx, query, modelType, modelID, teamID, permissionName)
138+
func (g *Gate) Check(ctx context.Context, modelType string, modelID any, permissionName string, guardName string, teamID any) (bool, error) {
139+
guardName = g.resolveGuardName(guardName)
140+
141+
var query string
142+
var args []any
143+
144+
if IsNilOrEmpty(teamID) {
145+
query = fmt.Sprintf(`
146+
SELECT 'role' AS type, r.name AS value
147+
FROM %s mhr
148+
JOIN %s r ON r.id = mhr.role_id
149+
WHERE mhr.model_type = $1
150+
AND mhr.model_id = $2
151+
AND mhr.team_id IS NULL
152+
AND r.guard_name = $3
153+
UNION ALL
154+
SELECT 'permission' AS type, p.name AS value
155+
FROM %s mhp
156+
JOIN %s p ON p.id = mhp.permission_id
157+
WHERE mhp.model_type = $1
158+
AND mhp.model_id = $2
159+
AND mhp.team_id IS NULL
160+
AND p.name = $4
161+
AND p.guard_name = $3
162+
`, g.cfg.ModelHasRolesTable, g.cfg.RolesTable, g.cfg.ModelHasPermissionsTable, g.cfg.PermissionsTable)
163+
args = []any{modelType, modelID, guardName, permissionName}
164+
} else {
165+
query = fmt.Sprintf(`
166+
SELECT 'role' AS type, r.name AS value
167+
FROM %s mhr
168+
JOIN %s r ON r.id = mhr.role_id
169+
WHERE mhr.model_type = $1
170+
AND mhr.model_id = $2
171+
AND mhr.team_id = $5
172+
AND r.guard_name = $3
173+
UNION ALL
174+
SELECT 'permission' AS type, p.name AS value
175+
FROM %s mhp
176+
JOIN %s p ON p.id = mhp.permission_id
177+
WHERE mhp.model_type = $1
178+
AND mhp.model_id = $2
179+
AND mhp.team_id = $5
180+
AND p.name = $4
181+
AND p.guard_name = $3
182+
`, g.cfg.ModelHasRolesTable, g.cfg.RolesTable, g.cfg.ModelHasPermissionsTable, g.cfg.PermissionsTable)
183+
args = []any{modelType, modelID, guardName, permissionName, teamID}
184+
}
185+
186+
rows, err := g.db.QueryContext(ctx, query, args...)
145187
if err != nil {
146188
return false, fmt.Errorf("wpd-gogate: query access records: %w", err)
147189
}
@@ -160,7 +202,7 @@ func (g *Gate) Check(ctx context.Context, modelType string, modelID any, permiss
160202

161203
if recordType == "role" {
162204
// Check the in-memory cache for this role
163-
if g.HasRolePermission(value, permissionName) {
205+
if g.HasRolePermission(guardName, value, permissionName) {
164206
return true, nil
165207
}
166208
}

gate_integration_test.go

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func TestIntegration_Postgres(t *testing.T) {
119119
}
120120

121121
// Associate permissions with roles
122-
editorRole := gate.Role("editor")
122+
editorRole := gate.Role("editor", "web")
123123
err = editorRole.GivePermissionTo(ctx, "publish:articles")
124124
if err != nil {
125125
t.Fatalf("failed to assign publish:articles to editor: %v", err)
@@ -129,29 +129,29 @@ func TestIntegration_Postgres(t *testing.T) {
129129
t.Fatalf("failed to assign read:articles to editor: %v", err)
130130
}
131131

132-
viewerRole := gate.Role("viewer")
132+
viewerRole := gate.Role("viewer", "web")
133133
err = viewerRole.GivePermissionTo(ctx, "read:articles")
134134
if err != nil {
135135
t.Fatalf("failed to assign read:articles to viewer: %v", err)
136136
}
137137

138-
writerRole := gate.Role("writer")
138+
writerRole := gate.Role("writer", "web")
139139
err = writerRole.GivePermissionTo(ctx, "create:articles")
140140
if err != nil {
141141
t.Fatalf("failed to assign create:articles to writer: %v", err)
142142
}
143143

144144
// Verify local cache updates
145-
if !gate.HasRolePermission("editor", "publish:articles") {
145+
if !gate.HasRolePermission("web", "editor", "publish:articles") {
146146
t.Error("expected editor to have publish:articles in cache")
147147
}
148-
if !gate.HasRolePermission("editor", "read:articles") {
148+
if !gate.HasRolePermission("web", "editor", "read:articles") {
149149
t.Error("expected editor to have read:articles in cache")
150150
}
151-
if !gate.HasRolePermission("writer", "create:articles") {
151+
if !gate.HasRolePermission("web", "writer", "create:articles") {
152152
t.Error("expected writer to have create:articles in cache")
153153
}
154-
if gate.HasRolePermission("viewer", "publish:articles") {
154+
if gate.HasRolePermission("web", "viewer", "publish:articles") {
155155
t.Error("viewer should not have publish:articles in cache")
156156
}
157157

@@ -163,7 +163,7 @@ func TestIntegration_Postgres(t *testing.T) {
163163
userInTeam := gate.Model("users", userID, teamID)
164164

165165
// Check initially has no access
166-
ok, err := userInTeam.Can(ctx, "read:articles")
166+
ok, err := userInTeam.Can(ctx, "read:articles", "web")
167167
if err != nil {
168168
t.Fatalf("Can failed: %v", err)
169169
}
@@ -172,13 +172,13 @@ func TestIntegration_Postgres(t *testing.T) {
172172
}
173173

174174
// Assign viewer role in teamID
175-
err = userInTeam.AssignRole(ctx, "viewer")
175+
err = userInTeam.AssignRole(ctx, "viewer", "web")
176176
if err != nil {
177177
t.Fatalf("failed to assign viewer role: %v", err)
178178
}
179179

180180
// Verify access allowed
181-
ok, err = userInTeam.Can(ctx, "read:articles")
181+
ok, err = userInTeam.Can(ctx, "read:articles", "web")
182182
if err != nil {
183183
t.Fatalf("Can failed: %v", err)
184184
}
@@ -188,7 +188,7 @@ func TestIntegration_Postgres(t *testing.T) {
188188

189189
// Verify check is scoped to teamID (checking otherTeamID should return false)
190190
userInOtherTeam := gate.Model("users", userID, otherTeamID)
191-
ok, err = userInOtherTeam.Can(ctx, "read:articles")
191+
ok, err = userInOtherTeam.Can(ctx, "read:articles", "web")
192192
if err != nil {
193193
t.Fatalf("Can failed: %v", err)
194194
}
@@ -198,18 +198,18 @@ func TestIntegration_Postgres(t *testing.T) {
198198

199199
// Test case: Fully isolated multi-workspace roles (writer in Team A, viewer in Team B)
200200
userTeamA := gate.Model("users", userID, teamID)
201-
err = userTeamA.AssignRole(ctx, "writer")
201+
err = userTeamA.AssignRole(ctx, "writer", "web")
202202
if err != nil {
203203
t.Fatalf("failed to assign writer role in Team A: %v", err)
204204
}
205205
userTeamB := gate.Model("users", userID, otherTeamID)
206-
err = userTeamB.AssignRole(ctx, "viewer")
206+
err = userTeamB.AssignRole(ctx, "viewer", "web")
207207
if err != nil {
208208
t.Fatalf("failed to assign viewer role in Team B: %v", err)
209209
}
210210

211211
// In Team A (writer role assigned), user has create:articles
212-
ok, err = userTeamA.Can(ctx, "create:articles")
212+
ok, err = userTeamA.Can(ctx, "create:articles", "web")
213213
if err != nil {
214214
t.Fatalf("Can failed: %v", err)
215215
}
@@ -218,14 +218,14 @@ func TestIntegration_Postgres(t *testing.T) {
218218
}
219219

220220
// In Team B (viewer role assigned), user has read:articles but NOT create:articles
221-
ok, err = userTeamB.Can(ctx, "read:articles")
221+
ok, err = userTeamB.Can(ctx, "read:articles", "web")
222222
if err != nil {
223223
t.Fatalf("Can failed: %v", err)
224224
}
225225
if !ok {
226226
t.Error("expected user to have read:articles access in Team B (viewer)")
227227
}
228-
ok, err = userTeamB.Can(ctx, "create:articles")
228+
ok, err = userTeamB.Can(ctx, "create:articles", "web")
229229
if err != nil {
230230
t.Fatalf("Can failed: %v", err)
231231
}
@@ -234,23 +234,23 @@ func TestIntegration_Postgres(t *testing.T) {
234234
}
235235

236236
// Clean up Team A's writer role and Team B's viewer role
237-
err = userTeamA.RemoveRole(ctx, "writer")
237+
err = userTeamA.RemoveRole(ctx, "writer", "web")
238238
if err != nil {
239239
t.Fatalf("failed to remove writer role from Team A: %v", err)
240240
}
241-
err = userTeamB.RemoveRole(ctx, "viewer")
241+
err = userTeamB.RemoveRole(ctx, "viewer", "web")
242242
if err != nil {
243243
t.Fatalf("failed to remove viewer role from Team B: %v", err)
244244
}
245245

246246
// Give direct permission override in teamID
247-
err = userInTeam.GivePermissionTo(ctx, "admin:settings")
247+
err = userInTeam.GivePermissionTo(ctx, "admin:settings", "web")
248248
if err != nil {
249249
t.Fatalf("failed to give direct permission: %v", err)
250250
}
251251

252252
// Verify direct permission works
253-
ok, err = userInTeam.Can(ctx, "admin:settings")
253+
ok, err = userInTeam.Can(ctx, "admin:settings", "web")
254254
if err != nil {
255255
t.Fatalf("Can failed: %v", err)
256256
}
@@ -259,7 +259,7 @@ func TestIntegration_Postgres(t *testing.T) {
259259
}
260260

261261
// Verify other team has no direct permission
262-
ok, err = userInOtherTeam.Can(ctx, "admin:settings")
262+
ok, err = userInOtherTeam.Can(ctx, "admin:settings", "web")
263263
if err != nil {
264264
t.Fatalf("Can failed: %v", err)
265265
}
@@ -268,13 +268,13 @@ func TestIntegration_Postgres(t *testing.T) {
268268
}
269269

270270
// Revoke direct permission
271-
err = userInTeam.RevokePermissionTo(ctx, "admin:settings")
271+
err = userInTeam.RevokePermissionTo(ctx, "admin:settings", "web")
272272
if err != nil {
273273
t.Fatalf("failed to revoke direct permission: %v", err)
274274
}
275275

276276
// Verify direct permission revoked
277-
ok, err = userInTeam.Can(ctx, "admin:settings")
277+
ok, err = userInTeam.Can(ctx, "admin:settings", "web")
278278
if err != nil {
279279
t.Fatalf("Can failed: %v", err)
280280
}
@@ -283,13 +283,13 @@ func TestIntegration_Postgres(t *testing.T) {
283283
}
284284

285285
// Revoke role viewer
286-
err = userInTeam.RemoveRole(ctx, "viewer")
286+
err = userInTeam.RemoveRole(ctx, "viewer", "web")
287287
if err != nil {
288288
t.Fatalf("failed to remove role: %v", err)
289289
}
290290

291291
// Verify role revoked
292-
ok, err = userInTeam.Can(ctx, "read:articles")
292+
ok, err = userInTeam.Can(ctx, "read:articles", "web")
293293
if err != nil {
294294
t.Fatalf("Can failed: %v", err)
295295
}

0 commit comments

Comments
 (0)