diff --git a/sui/api/api.go b/sui/api/api.go index dad95cf6..75b99069 100644 --- a/sui/api/api.go +++ b/sui/api/api.go @@ -25,6 +25,7 @@ var dsl = []byte(` "description": "Run the backend script, with Api prefix method", "path": "/run/*route", "method": "POST", + "guard": "-", "process": "sui.Run", "in": [":context", "$param.route", ":payload"], "out": { "status": 200, "type": "application/json" } diff --git a/sui/api/guards.go b/sui/api/guards.go index b2e3263d..74606341 100644 --- a/sui/api/guards.go +++ b/sui/api/guards.go @@ -26,7 +26,7 @@ var Guards = map[string]func(c *Request) error{ "query-jwt": guardQueryJWT, // Get JWT Token from query string "__tk" "cookie-jwt": guardCookieJWT, // Get JWT Token from cookie "__tk" "cookie-trace": guardCookieTrace, // Set sid cookie - "oauth": guardOAuth, // OAuth 2.1 guard + "oauth": guardOAuth, // OAuth 2.1 authentication (ACL check done in Run for API calls) } // JWT Bearer JWT @@ -94,8 +94,9 @@ func guardCookieTrace(r *Request) error { return nil } -// OAuth 2.1 guard using openapi/oauth service -// This guard only authenticates the user without ACL checks (suitable for page rendering) +// OAuth 2.1 guard - authentication only +// This guard validates the token and sets authorized info +// ACL checks are performed separately in Run() for API calls func guardOAuth(r *Request) error { if r.context == nil { return fmt.Errorf("Context is nil") @@ -107,7 +108,7 @@ func guardOAuth(r *Request) error { c := r.context - // Authenticate only (validates token and sets authorized info, no ACL check) + // Authenticate only (validates token and sets authorized info) if !oauth.OAuth.Authenticate(c) { return fmt.Errorf("Not authenticated") } diff --git a/sui/api/run.go b/sui/api/run.go index 8bf7fa20..bd2aef78 100644 --- a/sui/api/run.go +++ b/sui/api/run.go @@ -11,6 +11,7 @@ import ( "github.com/yaoapp/gou/process" "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/sui/core" ) @@ -108,14 +109,17 @@ func Run(process *process.Process) interface{} { return nil } - scriptCtx, err := script.NewContext(process.Sid, nil) + scriptCtx, err := script.NewContext(r.Sid, nil) if err != nil { return nil } defer scriptCtx.Close() // Pass authorized info to V8 context - if authorized := process.GetAuthorized(); authorized != nil { + // Priority: 1. From Request (set by SUI guard), 2. From Process (set by GOU handler) + if len(r.Authorized) > 0 { + scriptCtx.WithAuthorized(r.Authorized) + } else if authorized := process.GetAuthorized(); authorized != nil { if authMap := authorized.AuthorizedToMap(); len(authMap) > 0 { scriptCtx.WithAuthorized(authMap) } @@ -185,11 +189,19 @@ func (r *Request) apiGuard(method string, api *core.PageAPI) (int, error) { } // Build in guard - if guard, has := Guards[guard]; has { - err := guard(r) + if guardFunc, has := Guards[guard]; has { + err := guardFunc(r) if err != nil { return 403, err } + + // For OAuth guard, perform ACL check after authentication + if guard == "oauth" { + if err := r.enforceACL(); err != nil { + return 403, err + } + } + return 200, nil } @@ -202,11 +214,33 @@ func (r *Request) apiGuard(method string, api *core.PageAPI) (int, error) { return 200, nil } +// enforceACL performs ACL permission check for API calls +func (r *Request) enforceACL() error { + if r.context == nil { + return nil + } + + // Skip if ACL is not enabled + if acl.Global == nil || !acl.Global.Enabled() { + return nil + } + + // Enforce ACL + ok, err := acl.Global.Enforce(r.context) + if err != nil { + log.Error("[SUI] ACL enforcement failed: %v", err) + return err + } + + if !ok { + return fmt.Errorf("Access denied") + } + + return nil +} + func configWriter() { - for { - select { - case config := <-chConfig: - configs[config.Root] = config - } + for config := range chConfig { + configs[config.Root] = config } } diff --git a/sui/docs/agent-sui.md b/sui/docs/agent-sui.md index 6207441a..0113bbf8 100644 --- a/sui/docs/agent-sui.md +++ b/sui/docs/agent-sui.md @@ -171,11 +171,24 @@ Create `.config` for page settings: ```json { "title": "Page Title", - "guard": "bearer-jwt", - "cache": 3600 + "guard": "oauth", + "api": { + "defaultGuard": "oauth" + } } ``` +### Available Guards + +| Guard | Description | +| -------------- | ------------------------------------------ | +| `oauth` | OAuth 2.1 authentication (recommended) | +| `bearer-jwt` | Bearer token JWT authentication | +| `cookie-jwt` | Cookie-based JWT authentication | +| `-` | No authentication (public access) | + +> See [Page Configuration](./page-config.md) for complete configuration options. + ## Backend Scripts Each page can have a backend script: diff --git a/sui/docs/backend-scripts.md b/sui/docs/backend-scripts.md index a915e5dc..55d7ac7a 100644 --- a/sui/docs/backend-scripts.md +++ b/sui/docs/backend-scripts.md @@ -185,7 +185,26 @@ interface Request { sid: string; // Session ID theme: string; // Current theme locale: string; // Current locale - authorized?: Record; // OAuth info (when guard is "oauth") + authorized?: { + // OAuth info (when guard is "oauth") + sub?: string; // Subject identifier + user_id?: string; // User ID + team_id?: string; // Team ID (if team login) + tenant_id?: string; // Tenant ID (multi-tenancy) + client_id?: string; // OAuth client ID + session_id?: string; // Session ID + scope?: string; // OAuth scopes + remember_me?: boolean; // Remember me flag + + // Data access constraints (set by ACL) + constraints?: { + owner_only?: boolean; // Only access owner's data + creator_only?: boolean; // Only access creator's data + editor_only?: boolean; // Only access editor's data + team_only?: boolean; // Only access team's data + extra?: Record; // Custom constraints + }; + }; } ``` diff --git a/sui/docs/page-config.md b/sui/docs/page-config.md new file mode 100644 index 00000000..d679a8c9 --- /dev/null +++ b/sui/docs/page-config.md @@ -0,0 +1,261 @@ +# Page Configuration + +Each SUI page can have a configuration file (`.config`) that defines page-level settings including title, guards, caching, and API options. + +## File Naming + +Configuration files use the naming convention `.config`: + +``` +/pages/users/ +├── users.html +├── users.css +├── users.ts +├── users.json +├── users.config # Page configuration +└── users.backend.ts +``` + +## Configuration Structure + +```json +{ + "title": "Page Title", + "description": "Page description", + "guard": "oauth", + "cache": 3600, + "dataCache": 300, + "cacheStore": "redis", + "root": "/custom-root", + "seo": { + "title": "SEO Title", + "description": "SEO Description", + "keywords": "keyword1, keyword2", + "image": "/images/og-image.png", + "url": "https://example.com/page" + }, + "api": { + "prefix": "Api", + "defaultGuard": "oauth", + "guards": { + "PublicMethod": "-", + "AdminMethod": "bearer-jwt" + } + } +} +``` + +## Configuration Options + +### Basic Options + +| Option | Type | Description | Default | +| ------------- | ------ | -------------------------------- | ------- | +| `title` | string | Page title | - | +| `description` | string | Page description | - | +| `guard` | string | Guard for page rendering | - | +| `cache` | number | Page cache duration in seconds | 0 | +| `dataCache` | number | Data cache duration in seconds | 0 | +| `cacheStore` | string | Cache store name (e.g., "redis") | - | +| `root` | string | Custom root path for the page | - | + +### SEO Options + +```json +{ + "seo": { + "title": "SEO Title - Different from page title", + "description": "Meta description for search engines", + "keywords": "comma, separated, keywords", + "image": "/images/og-image.png", + "url": "https://example.com/canonical-url" + } +} +``` + +### API Options + +The `api` section configures guards for backend API methods (called via `$Backend().Call()`): + +```json +{ + "api": { + "prefix": "Api", + "defaultGuard": "oauth", + "guards": { + "MethodName": "guard-name" + } + } +} +``` + +| Option | Type | Description | Default | +| -------------- | ------ | --------------------------------- | ------- | +| `prefix` | string | Method prefix for API functions | "Api" | +| `defaultGuard` | string | Default guard for all API methods | - | +| `guards` | object | Per-method guard overrides | - | + +## Guards + +SUI supports the following built-in guards: + +| Guard | Description | +| -------------- | ----------------------------------------------- | +| `oauth` | OAuth 2.1 authentication (recommended) | +| `bearer-jwt` | Bearer token JWT authentication | +| `cookie-jwt` | Cookie-based JWT authentication | +| `query-jwt` | Query string JWT authentication (`?__tk=token`) | +| `cookie-trace` | Session tracking via cookie | +| `-` | No authentication (public access) | + +### Page Guard vs API Guard + +- **Page Guard** (`guard`): Controls access to page rendering +- **API Guard** (`api.defaultGuard` / `api.guards`): Controls access to backend API methods + +```json +{ + "guard": "oauth", + "api": { + "defaultGuard": "oauth", + "guards": { + "PublicSearch": "-" + } + } +} +``` + +In this example: + +- Page rendering requires OAuth authentication +- All API methods require OAuth by default +- `ApiPublicSearch` method is publicly accessible + +## Examples + +### Public Page + +```json +{ + "title": "Welcome", + "description": "Public landing page" +} +``` + +### Protected Page with OAuth + +```json +{ + "title": "Dashboard", + "guard": "oauth", + "api": { + "defaultGuard": "oauth" + } +} +``` + +### Mixed Access Page + +```json +{ + "title": "Product Catalog", + "guard": "-", + "api": { + "defaultGuard": "-", + "guards": { + "AddToCart": "oauth", + "Checkout": "oauth" + } + } +} +``` + +Page is public, most API methods are public, but cart and checkout require authentication. + +### Cached Page + +```json +{ + "title": "Blog Post", + "cache": 3600, + "dataCache": 300, + "guard": "-" +} +``` + +### Full Configuration Example + +```json +{ + "title": "User Settings", + "description": "Manage your account settings", + "guard": "oauth", + "cache": 0, + "dataCache": 60, + "seo": { + "title": "Account Settings | MyApp", + "description": "Configure your account preferences and security settings" + }, + "api": { + "defaultGuard": "oauth", + "guards": { + "GetPublicProfile": "-", + "UpdateProfile": "oauth", + "DeleteAccount": "oauth" + } + } +} +``` + +## Accessing Authorized Info + +When using `oauth` guard, the authorized user information is available in: + +### Backend Scripts + +```typescript +function ApiGetUserData(request: Request): any { + // Access OAuth info from request.authorized + const userId = request.authorized?.user_id; + const teamId = request.authorized?.team_id; + const clientId = request.authorized?.client_id; + const scope = request.authorized?.scope; + + // Access data constraints (set by ACL) + const ownerOnly = request.authorized?.constraints?.owner_only; + const teamOnly = request.authorized?.constraints?.team_only; + + return Process("models.user.Find", userId); +} +``` + +### Data Binding (`.json`) + +```json +{ + "userId": "$auth.user_id", + "teamId": "$auth.team_id" +} +``` + +### HTML Templates + +```html +

Welcome, User {{ $auth.user_id }}

+

Team: {{ $auth.team_id }}

+``` + +## Custom Guards + +You can use custom process-based guards: + +```json +{ + "guard": "scripts.guards.CheckAdmin", + "api": { + "defaultGuard": "scripts.guards.CheckPermission" + } +} +``` + +The guard process receives the request context and should throw an exception to deny access.