Phase 1-3: AList stock deployment, NocoDB 7-table schema, W1/W2 n8n workflows + docs

This commit is contained in:
2026-08-22 19:26:06 +08:00
commit 71acb88078
21 changed files with 2127 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# n8n Setup — Environment Variables
W1/W2 通过**一个 in-flow 的 "Load Env" Code 节点**(webhook 之后第 2 步)读取全部 env,
下游所有节点引用 `$('Load Env').item.json.VAR`。secrets 不硬编码进 workflow。
## 读取方式(已实测确认)
- **Code 节点内**:`$env.VAR` 可用(例 `$env.NOCODB_URL`)。✔
- **HTTP 节点表达式字段**(url / header / body expression):`$env` 和 `process.env` **都不可用**。✘
- 所以用**一个 Code 节点 "Load Env"** 在 `$env` 里调取全部 env 输出到 `$json`,
下游 HTTP 节点用 `{{ $('Load Env').item.json.VAR }}` 取值。
> 关键点:
> - Load Env **必须接在 webhook flow 里(第 2 步)**,不在 flow 里的 Code 节点不会执行。
> - Load Env **必须透传传入的字段**(`customerId` / `customerProductId`),否则下游丢数据。
### Load Env 模板(实测可用)
```js
// Step 2: read ALL env via $env (works in Code node), and pass through
// the webhook-derived fields so downstream keeps customerId.
const prev = $input.item.json;
return { json: {
customerId: prev.customerId,
eventType: prev.eventType,
NOCODB_URL: $env.NOCODB_URL,
NOCODB_TOKEN: $env.NOCODB_TOKEN,
T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,
ALIST_URL: $env.ALIST_URL,
ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,
ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,
ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',
} };
```
## 需要配置的 env(n8n 容器环境变量)
```
NOCODB_URL=https://nocodb.hoelee.com
NOCODB_TOKEN=<workspace token>
NOCODB_TABLE_CUSTOMERS=maqb9zgvza2te3k
NOCODB_TABLE_PRODUCTS=molyb3pxv81l75g
NOCODB_TABLE_RESOURCES=m6cm7tkxzd8m18c
NOCODB_TABLE_CUSTOMERPRODUCTS=mvyqbv8ao7u6bct
NOCODB_TABLE_ACCESSGRANTS=mxdx3yhfcyej3ao
NOCODB_LINK_PROD_RESOURCES=c4ko4x8kwi16swg
ALIST_URL=http://alist-serve-resource:5244
ALIST_ADMIN_USERNAME=hoelee
ALIST_ADMIN_PASSWORD=<admin 密码>
```
改完 env 必须**重建 n8n 容器**(不是 restart)。
## 导入步骤
1. n8n → Import from File → `W1-customer-provision.json` / `W2-customerproduct-sync.json`。
2. Webhook 节点 → Copy Webhook URL。
3. NocoDB → 对应表 → 建 Webhook(After Insert + After Update)粘 URL:
- Customers 表 → W1;CustomerProducts 表 → W2。
## AList user 创建顺序(重要)
`user/create` 传空 `role` 会失败(`admin or guest user can not be created`,因 AList 把空 role
自动填成 guest 后拒绝)。**必须先 `role/create` 建客户专属 role,再 `user/create` 传 `role:[新roleId]`。**
详见 `docs/API_INTEGRATION.md` §9.7。
## 验证状态
- ✅ W1 已实测跑通(create user 自动化成功)。
- W2 待测(同 Load Env 模式已套用)。
+591
View File
@@ -0,0 +1,591 @@
{
"name": "W1 - Customer Provision (AList User + Role)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "alist-customer-provision",
"responseMode": "onReceived",
"options": {}
},
"id": "trigger-webhook",
"name": "NocoDB: Customer Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "customer-provision"
},
{
"parameters": {
"jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '' } };\n"
},
"id": "extract-id",
"name": "Extract Customer Record Id",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"jsCode": "// Step 2: read ALL env via process.env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerId.\nconst e = process.env;\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: e.NOCODB_URL,\n NOCODB_TOKEN: e.NOCODB_TOKEN,\n T_CUSTOMERS: e.NOCODB_TABLE_CUSTOMERS,\n ALIST_URL: e.ALIST_URL,\n ALIST_ADMIN_USERNAME: e.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: e.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: e.ALIST_ADMIN_OTP || '',\n} };\n"
},
"id": "load-env",
"name": "Load Env",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records/{{ $json.customerId }}",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "xc-token",
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "fetch-customer",
"name": "Fetch Latest Customer Record",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
]
},
{
"parameters": {
"jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nreturn { json: { customerId: cust.Id ?? cust.id, username, password, displayName: cust.display_name ?? username, basePath: cust.base_path ?? '/Class', alistUserId: cust.alist_user_id ?? null, alistRoleId: cust.alist_role_id ?? null, isCreate: !(cust.alist_user_id) } };\n"
},
"id": "prepare",
"name": "Prepare Provision Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
0
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/auth/login",
"authentication": "none",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ username: $('Load Env').item.json.ALIST_ADMIN_USERNAME, password: $('Load Env').item.json.ALIST_ADMIN_PASSWORD }) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-login",
"name": "AList Admin Login",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
0
]
},
{
"parameters": {
"jsCode": "const resp = $input.item.json;\nconst token = resp?.data?.token;\nif (!token) throw new Error('AList admin login failed: ' + JSON.stringify(resp));\nreturn { json: { token } };\n"
},
"id": "extract-token",
"name": "Extract Admin Token",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeName": "boolean",
"ignoreCase": true
},
"conditions": [
{
"id": "cond-create",
"leftValue": "={{ $('Prepare Provision Payload').item.json.isCreate }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals",
"singleValue": true,
"rightType": "boolean"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "branch-create-update",
"name": "Create vs Update",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1320,
0
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/create",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Extract Admin Token').item.json.token }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ name: 'customer-' + $('Prepare Provision Payload').item.json.username, description: 'Auto role for ' + $('Prepare Provision Payload').item.json.username, default: false, permission_scopes: [] }) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-create-role",
"name": "AList Create Role",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1540,
-240
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/list?page=1&per_page=100",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Extract Admin Token').item.json.token }}"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-list-roles",
"name": "AList List Roles (find id)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
-240
]
},
{
"parameters": {
"jsCode": "const roles = $input.item.json?.data?.content ?? [];\nconst target = 'customer-' + $('Prepare Provision Payload').item.json.username;\nconst r = roles.find(x => x.name === target);\nif (!r) throw new Error('Role not found: ' + target);\nreturn { json: { alistRoleId: r.id, roleName: target } };\n"
},
"id": "find-role-id",
"name": "Find New Role Id",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
-240
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/create",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Extract Admin Token').item.json.token }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: [$('Find New Role Id').item.json.alistRoleId], permission: 16384 }) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-create-user",
"name": "AList Create User",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2200,
-240
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/list?page=1&per_page=100",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Extract Admin Token').item.json.token }}"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-list-users",
"name": "AList List Users (find id)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2420,
-240
]
},
{
"parameters": {
"jsCode": "const users = $input.item.json?.data?.content ?? [];\nconst target = $('Prepare Provision Payload').item.json.username;\nconst u = users.find(x => x.username === target);\nif (!u) throw new Error('Created user not found: ' + target);\nreturn { json: { alistUserId: u.id, username: target } };\n"
},
"id": "find-user-id",
"name": "Find New User Id",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2640,
-240
]
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "xc-token",
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
"options": {}
},
"id": "nocodb-writeback-create",
"name": "Write Back (Created)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2860,
-240
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/update",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Extract Admin Token').item.json.token }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ id: $('Prepare Provision Payload').item.json.alistUserId, username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: $('Prepare Provision Payload').item.json.alistRoleId ? [$('Prepare Provision Payload').item.json.alistRoleId] : [], permission: 16384 }) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-update-user",
"name": "AList Update User",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1540,
0
]
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "xc-token",
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
"options": {}
},
"id": "nocodb-writeback-update",
"name": "Write Back (Updated)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
0
]
}
],
"connections": {
"NocoDB: Customer Webhook": {
"main": [
[
{
"node": "Extract Customer Record Id",
"type": "main",
"index": 0
}
]
]
},
"Extract Customer Record Id": {
"main": [
[
{
"node": "Load Env",
"type": "main",
"index": 0
}
]
]
},
"Fetch Latest Customer Record": {
"main": [
[
{
"node": "Prepare Provision Payload",
"type": "main",
"index": 0
}
]
]
},
"Prepare Provision Payload": {
"main": [
[
{
"node": "AList Admin Login",
"type": "main",
"index": 0
}
]
]
},
"AList Admin Login": {
"main": [
[
{
"node": "Extract Admin Token",
"type": "main",
"index": 0
}
]
]
},
"Extract Admin Token": {
"main": [
[
{
"node": "Create vs Update",
"type": "main",
"index": 0
}
]
]
},
"Create vs Update": {
"main": [
[
{
"node": "AList Create Role",
"type": "main",
"index": 0
}
],
[
{
"node": "AList Update User",
"type": "main",
"index": 0
}
]
]
},
"AList Create Role": {
"main": [
[
{
"node": "AList List Roles (find id)",
"type": "main",
"index": 0
}
]
]
},
"AList List Roles (find id)": {
"main": [
[
{
"node": "Find New Role Id",
"type": "main",
"index": 0
}
]
]
},
"Find New Role Id": {
"main": [
[
{
"node": "AList Create User",
"type": "main",
"index": 0
}
]
]
},
"AList Create User": {
"main": [
[
{
"node": "AList List Users (find id)",
"type": "main",
"index": 0
}
]
]
},
"AList List Users (find id)": {
"main": [
[
{
"node": "Find New User Id",
"type": "main",
"index": 0
}
]
]
},
"Find New User Id": {
"main": [
[
{
"node": "Write Back (Created)",
"type": "main",
"index": 0
}
]
]
},
"Write Back (Created)": {
"main": []
},
"AList Update User": {
"main": [
[
{
"node": "Write Back (Updated)",
"type": "main",
"index": 0
}
]
]
},
"Write Back (Updated)": {
"main": []
},
"Load Env": {
"main": [
[
{
"node": "Fetch Latest Customer Record",
"type": "main",
"index": 0
}
]
]
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# W2 — CustomerProduct 变更 → 同步 AList Role Scopes
事件驱动:NocoDB CustomerProducts 表 webhook → n8n webhook → 重取记录 → 全量重算该 customer 的 effective grants → diff AList role scopes → apply → 物化 AccessGrants + 回写 sync_status。
## 核心逻辑(desired-state,规格 §19/§37)
1. webhook 只带 record Id,n8n `GET /api/v2/tables/{CustomerProducts}/records/{Id}` 重取完整记录。
2. 从记录解析 `customer` link(NocoDB link 字段返回关联行 Id + display value);
若返回对象数组,取第一个的 `Id`。
3. 拉取该 customer 所有 **status=active 且未过期** 的 CustomerProducts。
4. 每个 CP → `product` link → Product;再走 Products 的 M2M link 列
`c4ko4x8kwi16swg`(`GET /api/v2/tables/{Products}/links/c4ko4x8kwi16swg/records/{pid}`)
取该 Product 的全部 Resources(含 `alist_path`)。
5. 汇总:`alist_path -> MAX(expires_at)`(多 Product 重叠取最晚;规格 §18「取最大值」)。
8. 取 customer 的 `alist_role_id` → `GET /api/admin/role/get?id=` 读当前 scopes。
9. diff:desired paths vs 当前 scopes → 要加 / 要删。
10. `POST /api/admin/role/update` 整体重写 `permission_scopes`(幂等)。
11. 物化 AccessGrants:删掉该 customer 的 product 源 grants,重插 desired(manual 源保留不动)。
12. 回写 CustomerProducts 各记录 `sync_status=synced` + `last_sync`。
## 关键 fact(已验证)
- NocoDB link 字段在 v2 records GET 返回形如 `{"customer": {"Id": 5, "username": "..."}}` 或 `[{"Id":5}]`;
Code 节点需兼容两种。
- AList role create/update 返回 `data:null`;role/get 可回读 scopes。
- `permission=0`(read-only bitmask);role scope 用 `{path, permission:0}`。
- M2M link col id: Products.Resources = `c4ko4x8kwi16swg`。
## 首期不做
- expiry 定时扫描/移除(W1/W2 验证后再加)
- 密码 change-detection 的 password_updated_at 触发逻辑(W1 已留字段)
+184
View File
@@ -0,0 +1,184 @@
{
"name": "W2 - CustomerProduct Sync (AList Role Scopes)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "alist-cp-sync",
"responseMode": "onReceived",
"options": {}
},
"id": "trigger-webhook",
"name": "NocoDB: CustomerProduct Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "cp-sync"
},
{
"parameters": {
"jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CP record Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n"
},
"id": "extract-id",
"name": "Extract CP Record Id",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerProductId.\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n"
},
"id": "load-env",
"name": "Load Env",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) throw new Error('CP has no customer link');\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n return true;\n});\n\n// 3. build resource path -> max expires_at\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const path = res.alist_path;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n },\n};\n"
},
"id": "compute",
"name": "Compute Effective Grants",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/update",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Compute Effective Grants').item.json.token }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ id: $('Compute Effective Grants').item.json.roleId, name: $('Compute Effective Grants').item.json.roleName, description: $('Compute Effective Grants').item.json.roleDesc, permission_scopes: $('Compute Effective Grants').item.json.newScopes }) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "alist-apply-role",
"name": "AList Update Role Scopes",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
]
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERPRODUCTS }}/records",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "xc-token",
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
"options": {}
},
"id": "nocodb-writeback",
"name": "Write Back CP Sync Status",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
0
]
}
],
"connections": {
"NocoDB: CustomerProduct Webhook": {
"main": [
[
{
"node": "Extract CP Record Id",
"type": "main",
"index": 0
}
]
]
},
"Extract CP Record Id": {
"main": [
[
{
"node": "Load Env",
"type": "main",
"index": 0
}
]
]
},
"Compute Effective Grants": {
"main": [
[
{
"node": "AList Update Role Scopes",
"type": "main",
"index": 0
}
]
]
},
"AList Update Role Scopes": {
"main": [
[
{
"node": "Write Back CP Sync Status",
"type": "main",
"index": 0
}
]
]
},
"Load Env": {
"main": [
[
{
"node": "Compute Effective Grants",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}
+110
View File
@@ -0,0 +1,110 @@
// W2 core computation: effective desired grants + diff vs AList.
// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.
// Config read via process.env (n8n $env proxy is unreliable for custom vars).
const NC = process.env.NOCODB_URL;
const NC_TOKEN = process.env.NOCODB_TOKEN;
const ALIST = process.env.ALIST_URL;
const T_CUSTPROD = process.env.NOCODB_TABLE_CUSTOMERPRODUCTS;
const T_PROD = process.env.NOCODB_TABLE_PRODUCTS;
const T_CUST = process.env.NOCODB_TABLE_CUSTOMERS;
const LNK_PROD_RES = process.env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
async function ncGet(path) {
return await this.helpers.httpRequest({
method: 'GET',
url: NC + path,
headers: { 'xc-token': NC_TOKEN },
json: true,
});
}
async function alistReq(method, path, body, token) {
return await this.helpers.httpRequest({
method,
url: ALIST + path,
headers: token ? { Authorization: token } : {},
body,
json: true,
});
}
function linkId(v) {
if (v == null) return null;
if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;
if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;
return v;
}
const custProdId = $json.customerProductId;
// 1. re-fetch the CP record
const cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);
const customerId = linkId(cp.customer);
if (!customerId) throw new Error('CP has no customer link');
// 2. load all active CPs for this customer
const cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);
const active = (cps.list || []).filter(r => {
if (r.status && r.status !== 'active') return false;
return true;
});
// 3. build resource path -> max expires_at
const effective = {};
for (const c of active) {
const pid = linkId(c.product);
if (!pid) continue;
const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);
const exp = c.expires_at; // 'YYYY-MM-DD'
for (const res of (resList.list || [])) {
const path = res.alist_path;
if (!path) continue;
const cur = effective[path];
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
effective[path] = { expires: exp || null, permission: 0 };
}
}
}
// 4. admin login
const login = await alistReq('POST', '/api/auth/login', {
username: process.env.ALIST_ADMIN_USERNAME,
password: process.env.ALIST_ADMIN_PASSWORD,
}, null);
const token = login?.data?.token;
if (!token) throw new Error('AList login failed');
// customer's role id
const cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);
const roleId = cust.alist_role_id;
let currentScopes = [];
if (roleId) {
const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);
currentScopes = role?.data?.permission_scopes ?? [];
}
const desiredPaths = Object.keys(effective);
const currentPaths = currentScopes.map(s => s.path);
const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));
const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));
let roleName = null, roleDesc = null;
if (roleId) {
const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);
roleName = role?.data?.name;
roleDesc = role?.data?.description;
}
return {
json: {
customerId,
roleId,
roleName,
roleDesc,
token,
effective,
desiredPaths,
currentScopes,
diff: { toAdd, toRemove },
newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),
},
};