llzai/axonhub
0
1package scopes
2
3import (
4 "context"
5
6 "entgo.io/ent/dialect/sql"
7 "entgo.io/ent/entql"
8 "github.com/samber/lo"
9
10 "github.com/looplj/axonhub/internal/contexts"
11 "github.com/looplj/axonhub/internal/ent"
12 "github.com/looplj/axonhub/internal/ent/privacy"
13)
14
15type ProjectOwnedFilter interface {
16 WhereProjectID(entql.IntP)
17}
18
19// userHasProjectScope checks if a user has the required scope for a specific project.
20func userHasProjectScope(user *ent.User, projectID int, requiredScope ScopeSlug) bool {
21 if user.IsOwner {
22 return true
23 }
24
25 // Check if user has project membership with required scope
26 membership, found := lo.Find(user.Edges.ProjectUsers, func(projectUser *ent.UserProject) bool {
27 return projectUser.ProjectID == projectID
28 })
29
30 if !found {
31 return false
32 }
33
34 if membership.IsOwner {
35 return true
36 }
37
38 if hasScope(membership.Scopes, string(requiredScope)) {
39 return true
40 }
41
42 for _, role := range user.Edges.Roles {
43 if role.ProjectID != nil && *role.ProjectID == projectID {
44 if hasScope(role.Scopes, string(requiredScope)) {
45 return true
46 }
47 }
48 }
49
50 return false
51}
52
53// UserProjectScopeReadRule allows users to query projects they are members of.
54// It checks:
55// 1. If user has global scope permission -> Allow all
56// 2. If user is project member with required scope -> Filter by project membership.
57func UserProjectScopeReadRule(requiredScope ScopeSlug) privacy.QueryRule {
58 return privacy.FilterFunc(projectMemberQueryFilter(requiredScope))
59}
60
61func projectMemberQueryFilter(requiredScope ScopeSlug) func(ctx context.Context, q privacy.Filter) error {
62 return func(ctx context.Context, q privacy.Filter) error {
63 // Check if project ID is in context
64 projectID, hasProjectID := contexts.GetProjectID(ctx)
65 if !hasProjectID {
66 return privacy.Skipf("Project ID not found in context")
67 }
68
69 currentUser, err := getUserFromContext(ctx)
70 if err != nil {
71 return err
72 }
73
74 switch q := q.(type) {
75 case ProjectOwnedFilter:
76 // Check if user has global scope permission or project scope permission.
77 if !userHasSystemScope(currentUser, requiredScope) && !userHasProjectScope(currentUser, projectID, requiredScope) {
78 return privacy.Skipf("User %d can not query project %d with scope %s", currentUser.ID, projectID, requiredScope)
79 }
80
81 q.WhereProjectID(entql.IntEQ(projectID))
82
83 return privacy.Allowf("User %d can query project %d with scope %s", currentUser.ID, projectID, requiredScope)
84 case *ent.ProjectFilter:
85 if !userHasSystemScope(currentUser, requiredScope) && !userHasProjectScope(currentUser, projectID, requiredScope) {
86 return privacy.Skipf("User %d can not query project %d with scope %s", currentUser.ID, projectID, requiredScope)
87 }
88
89 q.WhereID(entql.IntEQ(projectID))
90
91 return privacy.Allowf("User %d can query project %d with scope %s", currentUser.ID, projectID, requiredScope)
92 default:
93 return privacy.Skipf("User %d can only query project %d with scope %s", currentUser.ID, projectID, requiredScope)
94 }
95 }
96}
97
98// UserProjectScopeWriteRule ensures users can only modify resources in projects they are members of.
99func UserProjectScopeWriteRule(requiredScope ScopeSlug) privacy.MutationRule {
100 return projectMemberMutationRule{requiredScope: requiredScope}
101}
102
103type ProjectOwnedMutation interface {
104 ent.Mutation
105 ProjectID() (r int, exists bool)
106 WhereP(ps ...func(*sql.Selector))
107}
108
109type projectMemberMutationRule struct {
110 requiredScope ScopeSlug
111}
112
113func (r projectMemberMutationRule) EvalMutation(ctx context.Context, m ent.Mutation) error {
114 user, err := getUserFromContext(ctx)
115 if err != nil {
116 return privacy.Skipf("User not found in context")
117 }
118
119 // For mutations, check project membership
120 switch mutation := m.(type) {
121 case ProjectOwnedMutation:
122 projectID, hasProjectID := contexts.GetProjectID(ctx)
123 if !hasProjectID {
124 return privacy.Skipf("Project ID not found in context")
125 }
126
127 if !userHasSystemScope(user, r.requiredScope) && !userHasProjectScope(user, projectID, r.requiredScope) {
128 return privacy.Skipf("User %d can not modify resources in project %d with scope %s", user.ID, projectID, r.requiredScope)
129 }
130
131 switch mutation.Op() {
132 case ent.OpCreate:
133 mProjectID, ok := mutation.ProjectID()
134 if !ok {
135 return privacy.Skipf("Project ID not found")
136 }
137
138 if mProjectID != projectID {
139 return privacy.Skipf("User %d can not create resources in project %d with scope %s", user.ID, mProjectID, r.requiredScope)
140 }
141
142 return privacy.Allowf("User %d can create resources in project %d", user.ID, mProjectID)
143 case ent.OpUpdateOne, ent.OpDeleteOne:
144 mutation.WhereP(func(s *sql.Selector) {
145 s.Where(sql.EQ("project_id", projectID))
146 })
147
148 return privacy.Allowf("User %d can modify resources in project %d", user.ID, projectID)
149 case ent.OpUpdate, ent.OpDelete:
150 mutation.WhereP(func(s *sql.Selector) {
151 s.Where(sql.EQ("project_id", projectID))
152 })
153
154 return privacy.Allowf("User %d can modify resources in project %d", user.ID, projectID)
155 default:
156 return privacy.Denyf("Unsupported operation %s", mutation.Op())
157 }
158 case *ent.ProjectMutation:
159 // Check if user has global scope permission
160 if userHasSystemScope(user, r.requiredScope) {
161 return privacy.Allowf("User %d can create project", user.ID)
162 }
163
164 if mutation.Op().Is(ent.OpCreate) {
165 return privacy.Skipf("User %d can not create project", user.ID)
166 }
167
168 mProjectID, mProjectIDExists := mutation.ID()
169 if !mProjectIDExists {
170 return privacy.Skipf("Project ID not found")
171 }
172
173 if userHasProjectScope(user, mProjectID, r.requiredScope) {
174 return privacy.Allowf("User %d can modify project %d", user.ID, mProjectID)
175 }
176
177 return privacy.Skipf("User %d can not modify project %d", user.ID, mProjectID)
178 default:
179 return privacy.Skipf("Not a project-related mutation")
180 }
181}
182 