Skip to content

Commit 2b900fd

Browse files
committed
Merge branch 'main' of github.com:xraph/forge
1 parent 5e8abd5 commit 2b900fd

File tree

7 files changed

+690
-8
lines changed

7 files changed

+690
-8
lines changed

di.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ type Container = vessel.Vessel
1010
// ProvideOption is an alias for vessel.ConstructorOption, used to configure options for constructing objects.
1111
type ProvideOption = vessel.ConstructorOption
1212

13-
// Scope represents a lifetime scope for scoped services
13+
// DIScope represents a lifetime scope for scoped services in the DI container.
1414
// Typically used for HTTP requests or other bounded operations.
15-
type Scope = vessel.Scope
15+
type DIScope = vessel.Scope
1616

1717
// Factory creates a service instance.
1818
type Factory = vessel.Factory

errors.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ var (
3535
ErrConfigErrorSentinel = errors.ErrConfigErrorSentinel
3636
)
3737

38+
// Scope identity errors.
39+
var (
40+
// ErrNoScope is returned when a Scope is required but not present in the context.
41+
ErrNoScope = errors.Unauthorized("scope identity required")
42+
43+
// ErrNoOrg is returned when an organization-level Scope is required
44+
// but the current Scope has no OrgID.
45+
ErrNoOrg = errors.Forbidden("organization scope required")
46+
)
47+
3848
// ServiceError represents a service-level error for backward compatibility.
3949
type ServiceError = errors.ServiceError
4050

interceptors/interceptors.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
// Package interceptors provides pre-built interceptors for forge routes.
2+
//
3+
// Interceptors run before the handler and make allow/block decisions.
4+
// They differ from middleware: middleware wraps the handler chain (before/after),
5+
// while interceptors only run before the handler with a simple allow/block/enrich decision.
6+
//
7+
// Usage:
8+
//
9+
// import "github.com/xraph/forge/interceptors"
10+
//
11+
// router.GET("/admin/users", handler,
12+
// interceptors.WithInterceptor(
13+
// interceptors.RequireAuth(),
14+
// interceptors.RequireRole("admin"),
15+
// ),
16+
// )
17+
package interceptors
18+
19+
import (
20+
"time"
21+
22+
"github.com/xraph/forge/internal/router"
23+
)
24+
25+
// ---------------------------------------------------------------------------
26+
// Core Types
27+
// ---------------------------------------------------------------------------
28+
29+
// Interceptor represents a named interceptor with metadata.
30+
type Interceptor = router.Interceptor
31+
32+
// InterceptorFunc is a function that inspects a request and decides
33+
// whether to allow it to proceed.
34+
type InterceptorFunc = router.InterceptorFunc
35+
36+
// InterceptorResult represents the outcome of an interceptor execution.
37+
type InterceptorResult = router.InterceptorResult
38+
39+
// Context is the forge request context.
40+
type Context = router.Context
41+
42+
// RouteInfo provides route information for inspection.
43+
type RouteInfo = router.RouteInfo
44+
45+
// RouteOption configures a route.
46+
type RouteOption = router.RouteOption
47+
48+
// GroupOption configures a route group.
49+
type GroupOption = router.GroupOption
50+
51+
// RateLimitResult contains rate limit check results.
52+
type RateLimitResult = router.RateLimitResult
53+
54+
// ---------------------------------------------------------------------------
55+
// Result Helpers
56+
// ---------------------------------------------------------------------------
57+
58+
var (
59+
// Allow returns a result that allows the request to proceed.
60+
Allow = router.Allow
61+
62+
// AllowWithValues allows the request and enriches the context with values.
63+
AllowWithValues = router.AllowWithValues
64+
65+
// Block returns a result that blocks the request with an error.
66+
Block = router.Block
67+
68+
// BlockWithValues blocks the request but still provides values.
69+
BlockWithValues = router.BlockWithValues
70+
)
71+
72+
// ---------------------------------------------------------------------------
73+
// Constructors
74+
// ---------------------------------------------------------------------------
75+
76+
var (
77+
// NewInterceptor creates a named interceptor from a function.
78+
NewInterceptor = router.NewInterceptor
79+
80+
// InterceptorFromFunc creates an anonymous interceptor from a function.
81+
InterceptorFromFunc = router.InterceptorFromFunc
82+
)
83+
84+
// ---------------------------------------------------------------------------
85+
// Route / Group Options
86+
// ---------------------------------------------------------------------------
87+
88+
// WithInterceptor adds interceptors to a route.
89+
func WithInterceptor(i ...Interceptor) RouteOption {
90+
return router.WithInterceptor(i...)
91+
}
92+
93+
// WithSkipInterceptor skips named interceptors for a route.
94+
func WithSkipInterceptor(names ...string) RouteOption {
95+
return router.WithSkipInterceptor(names...)
96+
}
97+
98+
// WithGroupInterceptor adds interceptors to all routes in a group.
99+
func WithGroupInterceptor(i ...Interceptor) GroupOption {
100+
return router.WithGroupInterceptor(i...)
101+
}
102+
103+
// WithGroupSkipInterceptor skips named interceptors for all routes in a group.
104+
func WithGroupSkipInterceptor(names ...string) GroupOption {
105+
return router.WithGroupSkipInterceptor(names...)
106+
}
107+
108+
// ---------------------------------------------------------------------------
109+
// Authentication Interceptors
110+
// ---------------------------------------------------------------------------
111+
112+
var (
113+
// RequireAuth requires authentication (checks "auth" or "user" in context).
114+
RequireAuth = router.RequireAuth
115+
116+
// RequireAuthProvider requires a specific auth provider.
117+
RequireAuthProvider = router.RequireAuthProvider
118+
)
119+
120+
// ---------------------------------------------------------------------------
121+
// Authorization Interceptors
122+
// ---------------------------------------------------------------------------
123+
124+
var (
125+
// RequireScopes requires ALL specified scopes.
126+
RequireScopes = router.RequireScopes
127+
128+
// RequireAnyScope requires ANY of the specified scopes.
129+
RequireAnyScope = router.RequireAnyScope
130+
131+
// RequireRole requires ANY of the specified roles.
132+
RequireRole = router.RequireRole
133+
134+
// RequireAllRoles requires ALL specified roles.
135+
RequireAllRoles = router.RequireAllRoles
136+
)
137+
138+
// ---------------------------------------------------------------------------
139+
// Tenant Interceptors
140+
// ---------------------------------------------------------------------------
141+
142+
var (
143+
// TenantIsolation validates tenant access from URL param against user context.
144+
TenantIsolation = router.TenantIsolation
145+
)
146+
147+
// ---------------------------------------------------------------------------
148+
// Feature Flag Interceptors
149+
// ---------------------------------------------------------------------------
150+
151+
var (
152+
// FeatureFlag checks if a feature is enabled using a checker function.
153+
FeatureFlag = router.FeatureFlag
154+
155+
// FeatureFlagFromContext checks a feature flag from the "feature-flags" context map.
156+
FeatureFlagFromContext = router.FeatureFlagFromContext
157+
)
158+
159+
// ---------------------------------------------------------------------------
160+
// Enrichment Interceptors
161+
// ---------------------------------------------------------------------------
162+
163+
var (
164+
// Enrich enriches the context with values from a loader function.
165+
Enrich = router.Enrich
166+
167+
// EnrichUser loads user data into context under the "user" key.
168+
EnrichUser = router.EnrichUser
169+
)
170+
171+
// EnrichFromService loads data from a DI service into context.
172+
func EnrichFromService[T any](serviceName string, loader func(ctx Context, svc T) (map[string]any, error)) Interceptor {
173+
return router.EnrichFromService[T](serviceName, loader)
174+
}
175+
176+
// ---------------------------------------------------------------------------
177+
// Metadata-Based Interceptors
178+
// ---------------------------------------------------------------------------
179+
180+
var (
181+
// RequireMetadata checks that a route metadata key matches an expected value.
182+
RequireMetadata = router.RequireMetadata
183+
184+
// RequireTag checks if a route has a specific tag.
185+
RequireTag = router.RequireTag
186+
)
187+
188+
// ---------------------------------------------------------------------------
189+
// Rate Limiting Interceptors
190+
// ---------------------------------------------------------------------------
191+
192+
var (
193+
// RateLimit creates a rate limit interceptor with a custom checker.
194+
RateLimit = router.RateLimit
195+
196+
// RateLimitByIP creates a rate limit interceptor keyed by client IP.
197+
RateLimitByIP = router.RateLimitByIP
198+
)
199+
200+
// ---------------------------------------------------------------------------
201+
// IP / Network Interceptors
202+
// ---------------------------------------------------------------------------
203+
204+
var (
205+
// AllowIPs only allows specific IP addresses.
206+
AllowIPs = router.AllowIPs
207+
208+
// DenyIPs blocks specific IP addresses.
209+
DenyIPs = router.DenyIPs
210+
)
211+
212+
// ---------------------------------------------------------------------------
213+
// Time-Based Interceptors
214+
// ---------------------------------------------------------------------------
215+
216+
// TimeWindow only allows requests during specific hours.
217+
func TimeWindow(startHour, endHour int, location *time.Location) Interceptor {
218+
return router.TimeWindow(startHour, endHour, location)
219+
}
220+
221+
var (
222+
// Maintenance blocks requests when maintenance mode is active.
223+
Maintenance = router.Maintenance
224+
)
225+
226+
// ---------------------------------------------------------------------------
227+
// Validation Interceptors
228+
// ---------------------------------------------------------------------------
229+
230+
var (
231+
// RequireHeader requires specific headers to be present.
232+
RequireHeader = router.RequireHeader
233+
234+
// RequireContentType requires a specific Content-Type.
235+
RequireContentType = router.RequireContentType
236+
)
237+
238+
// ---------------------------------------------------------------------------
239+
// Audit / Logging Interceptors
240+
// ---------------------------------------------------------------------------
241+
242+
// AuditLog logs access attempts via a logger function.
243+
func AuditLog(logger func(ctx Context, route RouteInfo, timestamp time.Time)) Interceptor {
244+
return router.AuditLog(logger)
245+
}
246+
247+
// ---------------------------------------------------------------------------
248+
// Custom Interceptor Helpers
249+
// ---------------------------------------------------------------------------
250+
251+
var (
252+
// FromFunc creates an anonymous interceptor from a simple function.
253+
FromFunc = router.FromFunc
254+
255+
// Named wraps an anonymous interceptor with a name.
256+
Named = router.Named
257+
)
258+
259+
// ---------------------------------------------------------------------------
260+
// Combinator Interceptors
261+
// ---------------------------------------------------------------------------
262+
263+
var (
264+
// And combines interceptors — ALL must pass (short-circuits on first block).
265+
And = router.And
266+
267+
// Or combines interceptors — ANY one passing is sufficient.
268+
Or = router.Or
269+
270+
// Not inverts an interceptor's decision.
271+
Not = router.Not
272+
273+
// When conditionally executes an interceptor based on a predicate.
274+
When = router.When
275+
276+
// Unless conditionally skips an interceptor based on a predicate.
277+
Unless = router.Unless
278+
279+
// IfMetadata conditionally executes an interceptor based on route metadata.
280+
IfMetadata = router.IfMetadata
281+
282+
// IfTag conditionally executes an interceptor based on route tags.
283+
IfTag = router.IfTag
284+
285+
// ChainInterceptors combines multiple interceptors into a single one (alias for And).
286+
ChainInterceptors = router.ChainInterceptors
287+
)
288+
289+
// ---------------------------------------------------------------------------
290+
// Parallel Interceptors
291+
// ---------------------------------------------------------------------------
292+
293+
var (
294+
// Parallel executes interceptors concurrently — ALL must pass.
295+
Parallel = router.Parallel
296+
297+
// ParallelAny executes interceptors concurrently — ANY one passing is sufficient.
298+
ParallelAny = router.ParallelAny
299+
)

internal/router/interceptors.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,38 @@ func RequireAllRoles(roles ...string) Interceptor {
129129

130130
// TenantIsolation creates an interceptor that validates tenant access.
131131
// Compares the tenant from the URL param with the user's tenant.
132+
// Checks "user.tenantId" context value first, then falls back to the
133+
// forge Scope's OrgID (from "forge:scope") for compatibility with the
134+
// universal scope identity system.
132135
func TenantIsolation(tenantParamName string) Interceptor {
133136
return NewInterceptor("tenant-isolation", func(ctx Context, route RouteInfo) InterceptorResult {
134137
requestTenantID := ctx.Param(tenantParamName)
135138
if requestTenantID == "" {
136139
return Allow() // No tenant in request, skip check
137140
}
138141

139-
userTenantID := ctx.Get("user.tenantId")
140-
if userTenantID == nil {
141-
return Block(Forbidden("tenant access denied"))
142+
// Check legacy user.tenantId first
143+
if userTenantID := ctx.Get("user.tenantId"); userTenantID != nil {
144+
if requestTenantID != userTenantID {
145+
return Block(Forbidden("cross-tenant access denied"))
146+
}
147+
return Allow()
142148
}
143149

144-
if requestTenantID != userTenantID {
145-
return Block(Forbidden("cross-tenant access denied"))
150+
// Fallback: check forge Scope's OrgID (duck-typed to avoid circular import)
151+
type scopeWithOrg interface {
152+
OrgID() string
153+
}
154+
if scopeVal := ctx.Get("forge:scope"); scopeVal != nil {
155+
if s, ok := scopeVal.(scopeWithOrg); ok && s.OrgID() != "" {
156+
if requestTenantID != s.OrgID() {
157+
return Block(Forbidden("cross-tenant access denied"))
158+
}
159+
return Allow()
160+
}
146161
}
147162

148-
return Allow()
163+
return Block(Forbidden("tenant access denied"))
149164
})
150165
}
151166

0 commit comments

Comments
 (0)