Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ff66de88 | ||
|
|
03af9df3e4 | ||
|
|
80eb2b71be | ||
|
|
89b769cfd9 | ||
|
|
21e3a3b45f | ||
|
|
9bcdfc2049 | ||
|
|
3ecee3099e |
@@ -35,6 +35,9 @@ alist-serve-resource/
|
||||
├── .gitignore
|
||||
├── docs/
|
||||
│ ├── API_INTEGRATION.md # ★ AList API 源码级验证
|
||||
│ ├── ARCHITECTURE.md # 三层职责与数据流
|
||||
│ ├── PERMISSIONS.md # 权限计算规则
|
||||
│ ├── ADMIN_GUIDE.md # ★ 管理员操作指南(华文,极简)
|
||||
│ ├── INSTALL.md
|
||||
│ ├── CONFIGURATION.md
|
||||
│ ├── DATABASE_SCHEMA.md
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# 管理员操作指南(ADMIN_GUIDE)
|
||||
|
||||
> 本指南只讲在 NocoDB 里怎么点。不需要懂技术,照着做即可。
|
||||
> 所有操作都在 NocoDB 的 `AlistAccess` 工作区里完成,系统会自动处理后续。
|
||||
|
||||
---
|
||||
|
||||
## 第一部分:给顾客开账号 + 授权(3 步)
|
||||
|
||||
> 顺序:先建顾客 → 再建购买记录 → 再挂产品。
|
||||
|
||||
### 第 1 步 — 建顾客(Customers 表)
|
||||
|
||||
打开 `Customers` 表,点「+新增记录」,**按顺序填**:
|
||||
|
||||
| 顺序 | 栏位 | 必填 | 怎么填 |
|
||||
|---|---|---|---|
|
||||
| 1 | `username` | ✅ | 顾客登录用的账号名,**不能重复** |
|
||||
| 2 | `password` | ✅ | 顾客的登录密码 |
|
||||
| 3 | `display_name` | 选填 | 顾客显示名(顾客姓名等)|
|
||||
| 4 | `email` | 选填 | 顾客邮箱 |
|
||||
| 5 | `status` | ✅ | 先选 `active`(顾客马上能用)|
|
||||
|
||||
> **填完会看到**:保存后,下面几个栏位(`alist_user_id`、`alist_role_id`、`sync_status`)
|
||||
> 会被系统自动填上,`sync_status` 变成 `synced`。这是正常的,不用管。
|
||||
>
|
||||
> **注意**:如果 `status` 先选 `pending`(还没决定卖什么),系统不会建账号。
|
||||
> 等你后面改成 `active` 才会正式开账号。
|
||||
|
||||
### 第 2 步 — 记购买(Purchases 表)
|
||||
|
||||
打开 `Purchases` 表,点「+新增记录」,**期望填写**:
|
||||
|
||||
| 栏位 | 必填 | 怎么填 |
|
||||
|---|---|---|
|
||||
| `customer` | ✅ | 选第 1 步那个顾客 |
|
||||
| `product` | ✅ | 选顾客买的产品 |
|
||||
| `purchase_date` | 选填 | 购买日期 |
|
||||
| `amount` | 选填 | 金额 |
|
||||
| `payment_method` | 选填 | 付款方式 |
|
||||
| `order_id` | 选填 | 订单号 |
|
||||
| `notes` | 选填 | 备注 |
|
||||
|
||||
> 这份是**记账留档**用,填不填都不影响顾客能不能看文件。
|
||||
|
||||
### 第 3 步 — 挂产品(CustomerProducts 表)
|
||||
|
||||
> **这步才是真正让顾客「看得到文件」的关键。**
|
||||
|
||||
打开 `CustomerProducts` 表,点「+新增记录」,**按顺序填**:
|
||||
|
||||
| 顺序 | 栏位 | 必填 | 怎么填 |
|
||||
|---|---|---|---|
|
||||
| 1 | `customer` | ✅ | 选第 1 步那个顾客 |
|
||||
| 2 | `product` | ✅ | 选他买的产品 |
|
||||
| 3 | `start_at` | 选填 | 开始日期 |
|
||||
| 4 | `expires_at` | ✅ | 到期日期(**到期当天就失效**)|
|
||||
| 5 | `status` | ✅ | 选 `active` |
|
||||
|
||||
> **填完会看到**:顾客登录后就能看到这个产品对应的文件夹。
|
||||
> 到了 `expires_at` 那天,权限自动失效。
|
||||
>
|
||||
> **一个顾客可以有多条** CustomerProducts:买几个产品就建几条,权限会全加起来。
|
||||
|
||||
---
|
||||
|
||||
## 第二部分:准备产品(Products + Resources,2 步)
|
||||
|
||||
> 顺序:先建资源(文件夹)→ 再建产品(打包)。
|
||||
|
||||
### 第 1 步 — 建资源(Resources 表)
|
||||
|
||||
打开 `Resources` 表,点「+新增记录」,**按顺序填**:
|
||||
|
||||
| 顺序 | 栏位 | 必填 | 怎么填 |
|
||||
|---|---|---|---|
|
||||
| 1 | `name` | ✅ | 给人看的名字(如「Forex 课程」)|
|
||||
| 2 | `alist_path` | ✅ | 文件夹路径,以 `/` 开头,如 `/Class/Forex` |
|
||||
|
||||
> **注意**:`alist_path` 必须是真实存在的文件夹路径,填错顾客会打不开。
|
||||
|
||||
### 第 2 步 — 建产品(Products 表)
|
||||
|
||||
打开 `Products` 表,点「+新增记录」,**按顺序填**:
|
||||
|
||||
| 顺序 | 栏位 | 必填 | 怎么填 |
|
||||
|---|---|---|---|
|
||||
| 1 | `name` | ✅ | 产品名(如「Python Basic」)|
|
||||
| 2 | `status` | ✅ | 选 `active` |
|
||||
| 3 | `Resources` | ✅ | 勾选这个产品**包含**的资源(可勾多个)|
|
||||
|
||||
> **填完会看到**:以后在「第一部分第 3 步」给顾客挂这个产品时,
|
||||
> 产品里勾选的资源路径会一次性算给顾客。
|
||||
|
||||
---
|
||||
|
||||
## 附:Customers 的 status 怎么选
|
||||
|
||||
| status | 意思 | 顾客能看文件吗 | 什么时候用 |
|
||||
|---|---|---|---|
|
||||
| `pending` | 还没正式开通 | ❌ | 刚建顾客、还没卖产品时 |
|
||||
| `active` | 正常使用 | ✅ | 顾客在使用中 |
|
||||
| `suspended` | 暂时停权 | ❌ | 欠费 / 有问题暂时停 |
|
||||
| `expired` | 整户到期 | ❌ | 顾客服务整体结束 |
|
||||
| `deleted` | 停用(保留记录)| ❌ | 不合作了但想留历史 |
|
||||
|
||||
- 只要不是 `active`,顾客就登不进去、也看不到文件。
|
||||
- 改回 `active`,系统会自动算**现在还有效**的产品权限(已经过期的不会回来)。
|
||||
@@ -0,0 +1,72 @@
|
||||
# 架构说明(Architecture)
|
||||
|
||||
> 本文解释系统的分层职责与数据流。更新日期 2026-08-23。
|
||||
|
||||
## 三层职责(核心心智模型)
|
||||
|
||||
| 层 | 载体 | 职责 | 谁改它 |
|
||||
|---|---|---|---|
|
||||
| **Account lifecycle** | `Customers.status` | 账户整体状态开关(active / pending / suspended / expired / deleted)| 管理员手工 |
|
||||
| **Product entitlement** | `CustomerProducts` + `Products` + `Resources` | 「这个客户买了哪些产品 → 能访问哪些路径」+ 每个产品的到期时间 | 管理员手工 |
|
||||
| **Effective permission** | AList Role `permission_scopes` | 最终生效的路径授权(由 n8n 从上面两层计算出来,写到 AList)| **仅 n8n**,管理员不直接碰 |
|
||||
|
||||
```
|
||||
Customers.status (account-level:整户开关)
|
||||
+
|
||||
CustomerProducts (product entitlement:买了什么、何时到期)
|
||||
+
|
||||
Product → Resources (产品包含哪些路径)
|
||||
+
|
||||
CustomerProduct.expires_at
|
||||
↓
|
||||
calculateEffectiveGrants (n8n 共享算法,单一事实源)
|
||||
↓
|
||||
AList Role.permission_scopes (effective/materialized permission)
|
||||
```
|
||||
|
||||
## 关键原则
|
||||
|
||||
1. **`Customers.status` 不进入权限计算**。它只决定「整户开关」:
|
||||
- `active` → 允许计算并应用 grants
|
||||
- `pending` / `suspended` / `expired` / `deleted` → AList user disabled + scopes 清空(账户级封禁)
|
||||
- 权限的「细粒度」永远来自 CustomerProducts 的到期时间,不由 status 替代。
|
||||
|
||||
2. **AccessGrants 是预留的物化表,当前不经 n8n 写入**。将来(W4)可用于 reconciliation 日志/物化审计,但现在不作为 source-of-truth,也不参与计算。
|
||||
|
||||
3. **n8n 是同步器,不是 SOT**。Source of Truth = NocoDB;AList 只做 enforcement。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
管理员在 NocoDB 改 Customers.status 或 CustomerProducts
|
||||
│ (NocoDB webhook:after insert / after update)
|
||||
▼
|
||||
n8n W1 (Customer webhook) ── 处理整户 status lifecycle
|
||||
n8n W2 (CustomerProduct webhook) ── 重算该客有效 grants 并应用
|
||||
n8n W3 (每 5 分钟 schedule) ── 全量对账:重算所有客户,修 drift
|
||||
│
|
||||
▼
|
||||
AList Admin API(role/update 写 permission_scopes;user/update 写 disabled)
|
||||
```
|
||||
|
||||
- **W1** 单一 Code 节点:provision + status lifecycle(5 状态)+ 幂等 write-back。
|
||||
- **W2** 单一 Code 节点:CustomerProduct 变更 → 重算该客 grants → diff → 应用。
|
||||
- **W3** 单一 Code 节点:全量 sweep,逐客调共享算法,diff 后应用;支持 dry-run / auto-disable 开关。
|
||||
|
||||
三个 workflow 共用同一个 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`,构建时内联),保证三处算法零复制、零漂移。
|
||||
|
||||
## Status lifecycle(W1)
|
||||
|
||||
| status | AList user | scopes | 说明 |
|
||||
|---|---|---|---|
|
||||
| `pending`(无 user)| —(不建)| — | 纯标签,customer 未正式开通 |
|
||||
| `pending`/`suspended`/`expired`/`deleted`(有 user)| disabled | 清空 | 账户级封禁,soft,保留历史 |
|
||||
| `active`(无 user)| 新建 + enabled | 计算应用 | 首次开通 |
|
||||
| `active`(有 user)| re-enable | 重算 + 应用 | 恢复/续期,永不恢复旧值 |
|
||||
| 任意 `X → active` | enable | 重算(仅当前有效)| resume,忽略已过期产品 |
|
||||
|
||||
## Loop 防护
|
||||
|
||||
- n8n 只写回 bookkeeping 字段(`alist_user_id`/`alist_role_id`/`sync_status`/`sync_required`/`last_sync`/`last_sync_error`)。
|
||||
- W1 用 **old/new field comparison**(NocoDB webhook 的 `previous_rows` vs `rows`)区分「管理员业务变更」vs「自己的 write-back echo」:echo 直接短路,零 PATCH。
|
||||
- `writeBack()` 有值比对守卫:字段值没变就不发 PATCH,从物理上掐断 echo loop。
|
||||
+14
-1
@@ -52,12 +52,25 @@
|
||||
| notes | LongText | |
|
||||
| alist_user_id | Number | AList 用户 ID(n8n 回填) |
|
||||
| alist_role_id | Number | AList role ID(n8n 回填) |
|
||||
| status | SingleSelect | pending/active/suspended/expired/disabled/deleted |
|
||||
| status | SingleSelect | pending/active/suspended/expired/deleted(account-level 整户开关,见下) |
|
||||
| sync_status | SingleSelect | pending/syncing/synced/error/unknown |
|
||||
| sync_required | Checkbox | 系统维护,管理员勿手改 |
|
||||
| last_sync | DateTime | |
|
||||
| last_sync_error | LongText | |
|
||||
|
||||
**Customers.status 语义(account-level 整户开关,不替代 CustomerProduct 到期):**
|
||||
|
||||
| 值 | 含义 | AList user | scopes |
|
||||
|---|---|---|---|
|
||||
| `pending` | 已建但未正式开通 | 若无则**不建**;有则 disabled | empty |
|
||||
| `active` | 正常使用 | enabled | 按当前有效 CP 重算 |
|
||||
| `suspended` | 管理员暂时封禁 | disabled | empty |
|
||||
| `expired` | 整户生命周期结束 | disabled | empty |
|
||||
| `deleted` | 软删除(保留账号+历史)| disabled | empty |
|
||||
|
||||
- 任意 `X → active` 恢复时,重算当前有效 grants(忽略已过期产品),不恢复旧 scopes。
|
||||
- status 变化**不自动改** CustomerProducts(不批量改 status/expires_at、不删除)。
|
||||
|
||||
### 2. Products
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
+51
-38
@@ -1,54 +1,67 @@
|
||||
# n8n Workflow 说明(Phase 3–6 — 待实施)
|
||||
# n8n Workflow 说明(已实施)
|
||||
|
||||
> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是同步器,不是 SOT。
|
||||
> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是**同步器,不是 SOT**。
|
||||
> 更新日期 2026-08-23。
|
||||
|
||||
## 依赖的 AList API(已源码验证,见 API_INTEGRATION.md)
|
||||
## 已实现的三个 Workflow
|
||||
|
||||
- 登录换 token:`POST http://alist-serve-resource:5244/api/auth/login`
|
||||
- User:`/api/admin/user/{list,get,create,update,delete,cancel_2fa}`
|
||||
- Role:`/api/admin/role/{list,get,create,update,delete}`
|
||||
| Workflow | ID | 触发 | 职责 |
|
||||
|---|---|---|---|
|
||||
| **W1** Customer Provision & Status Lifecycle | `gU2ee9Eg5w1KzB5p` | NocoDB Customers webhook | provision + 整户 status lifecycle |
|
||||
| **W2** CustomerProduct Sync | `D9OWAI7JUjKnRdOn` | NocoDB CustomerProducts webhook | 重算该客有效 grants 并应用 |
|
||||
| **W3** Expiry Sync | schedule `*/5` | 定时 | 全量对账 + drift 修复 |
|
||||
|
||||
## 需要的 Workflow
|
||||
三个 workflow 共享同一套算法 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`,
|
||||
构建时经 `gen_w1/w2/w3.js` 内联进各 Code 节点)。
|
||||
|
||||
### WF1 — Customer Create(trigger: Customers 新记录)
|
||||
1. 校验 customer。
|
||||
2. 查重 username → 建/改 AList user(`base_path=/Class`,`role=[专属role]`,`permission=16384`)。
|
||||
3. 建专属 role(`name=customer-<username>`,空 scopes)。
|
||||
4. 用 `/api/admin/user/update` 把 user 绑定到新 role(显式传 role ID,勿留空)。
|
||||
5. 回填 `alist_user_id` / `alist_role_id` 到 NocoDB。
|
||||
6. `sync_status=synced`。
|
||||
## W1 — Customer Provision & Status Lifecycle
|
||||
|
||||
### WF2 — AccessGrant Create/Update(trigger: AccessGrants 记录变更)
|
||||
1. 找 customer + resource path。
|
||||
2. 判断当前日期 vs start_at/expires_at。
|
||||
3. 若 active → 把该 path 并入该客专属 role 的 `permission_scopes`(permission=0)。
|
||||
4. 若非 active → 不加入。
|
||||
5. 整体 `post /api/admin/role/update` 重写 scopes(幂等)。
|
||||
6. 记录 `sync_status` / `last_sync_at` / `last_sync_error`。
|
||||
- 触发:Customers 表 after-insert / after-update(webhook `alist-customer-provision`)
|
||||
- 单一 Code 节点,处理 5 状态:
|
||||
- `active`(无 user)→ 建 role + 建 user + 重算 grants + enable
|
||||
- `active`(有 user)→ re-enable + 重算 grants(不恢复旧值)+ 应用
|
||||
- `pending`/`suspended`/`expired`/`deleted`(有 user)→ disable + 清空 scopes
|
||||
- `pending`/`suspended`/`expired`/`deleted`(无 user)→ no-op
|
||||
- 回填 `alist_user_id` / `alist_role_id`,写 `sync_status=synced`
|
||||
|
||||
### WF3 — Expiry Sync(schedule: every 5 min)
|
||||
1. 查所有 active/scheduled grants。
|
||||
2. 计算 desired scopes(NocoDB 有效 grants 并集)。
|
||||
3. 对比 AList 实际 scopes(`role/get`)。
|
||||
4. 移除过期 scope、加入新生效 scope。
|
||||
5. 若无剩余 active grant 且政策启用 → disable 该 customer(`user/update disabled=true`)。
|
||||
6. 记录结果;失败发通知。
|
||||
## W2 — CustomerProduct Sync
|
||||
|
||||
### WF4 — Full Reconciliation(schedule: daily)
|
||||
- desired(NocoDB) vs actual(AList) 全量 diff,修复 AList,记录 reconciliation。
|
||||
- 触发:CustomerProducts 表 after-insert / after-update(webhook `alist-customerproduct-sync`)
|
||||
- 读 CP → 定位 customer → 重算该客 grants → diff AList role → 有变化才 `role/update` 整体重写
|
||||
- `customer` link 为空时优雅跳过(不回写)
|
||||
|
||||
### WF5 — Customer Suspension(trigger: status=suspended)
|
||||
- `user/update disabled=true` + 清空 role scopes;恢复时重算。
|
||||
## W3 — Expiry Sync(全量对账)
|
||||
|
||||
### WF6 — Customer Delete(soft delete)
|
||||
- NocoDB `status=deleted`;AList 禁用用户 + 清空 scopes,不物理删除。
|
||||
- 触发:每 5 分钟 schedule
|
||||
- 逐客调 `calculateEffectiveGrants` → diff → 应用
|
||||
- Env 开关:`W3_DRY_RUN=true`(只打印计划)、`ALIST_AUTO_DISABLE_ON_EXPIRE=true`(scopes 变空时 disable 用户)
|
||||
|
||||
## 幂等规则(规格 §28)
|
||||
## 共用 env 变量
|
||||
|
||||
n8n Code 节点内用 `$env.VAR`(`process.env` 不可靠)。下表是本项目需要的 env:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `NOCODB_URL` / `NOCODB_TOKEN` | NocoDB 地址 + `xc-token` |
|
||||
| `NOCODB_TABLE_CUSTOMERS` / `..._CUSTOMERPRODUCTS` / `..._PRODUCTS` / `..._RESOURCES` / `..._ACCESSGRANTS` | 各表 model_id |
|
||||
| `NOCODB_LINK_PROD_RESOURCES` | Product↔Resource M2M 列 id(`c4ko4x8kwi16swg`)|
|
||||
| `ALIST_URL` | `http://alist-serve-resource:5244`(n8n 容器内)|
|
||||
| `ALIST_ADMIN_USERNAME` / `ALIST_ADMIN_PASSWORD` | AList admin 登录 |
|
||||
| `W3_DRY_RUN` / `ALIST_AUTO_DISABLE_ON_EXPIRE` | W3 开关 |
|
||||
|
||||
## 幂等规则
|
||||
|
||||
所有操作 = get current → compare → apply diff。重跑不产生重复 user/role/scope。
|
||||
更新 scope 一律「整体重写该 role 的 `permission_scopes`」。
|
||||
|
||||
## 待定项(实施时确认)
|
||||
## Loop 防护(重要)
|
||||
|
||||
- n8n 内网访问 AList 的 host 名(预期 `http://alist-serve-resource:5244`)。
|
||||
- 登录 token 缓存 / 刷新策略(JWT_EXPIRE=2h → 建议每次 workflow 起手先 login)。
|
||||
- n8n 只写回 bookkeeping 字段(`alist_user_id`/`alist_role_id`/`sync_status`/`sync_required`/`last_sync`/`last_sync_error`)。
|
||||
- W1 用 **old/new field comparison**(NocoDB `previous_rows` vs `rows`)区分「业务变更」vs「echo」;echo 直接短路。
|
||||
- `writeBack()` 值比对守卫:字段值不变就不发 PATCH,掐断 echo loop。
|
||||
|
||||
## 依赖的 AList API(源码级验证,见 API_INTEGRATION.md)
|
||||
|
||||
- 登录:`POST /api/auth/login`
|
||||
- User:`/api/admin/user/{list,get,create,update,delete}`
|
||||
- Role:`/api/admin/role/{list,get,create,update,delete}`(delete 用 `?id=` query param)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 权限模型说明(Permissions)
|
||||
|
||||
> 本文解释「最终生效的 AList 权限」是怎么从 NocoDB 数据算出来的。更新日期 2026-08-23。
|
||||
|
||||
## 最终生效权限的三要素
|
||||
|
||||
一个客户最终能访问哪些 AList 路径,由三样东西决定:
|
||||
|
||||
1. `Customers.status` — 整户开关(account-level)
|
||||
2. `CustomerProducts` — 买了哪些产品 + 何时到期(product entitlement)
|
||||
3. `Product → Resources` — 每个产品包含哪些路径(M2M),资源有 `alist_path`
|
||||
|
||||
三者合起来 → n8n 调 `calculateEffectiveGrants` → 算出 desired scopes → 写到 AList Role。
|
||||
|
||||
## 计算规则(calculateEffectiveGrants)
|
||||
|
||||
对某个客户,desired scopes = 以下两类的**并集**:
|
||||
|
||||
### (A) CustomerProduct 派生
|
||||
遍历该客所有 CustomerProduct:
|
||||
- 跳过 `status != active` 的
|
||||
- 跳过 `expires_at <= 今天` 的(**到期当天视为已过期**)
|
||||
- 通过 `product` link → Product 的 M2M Resources → 拿到每个 resource 的路径
|
||||
|
||||
### (B) AccessGrant 派生(预留,当前不经 n8n 写入)
|
||||
- 同 (A),跳过非 active / 已过期
|
||||
|
||||
### 合并规则
|
||||
- 同一个 path 来自多个来源 → 取 **MAX(expires_at)**(两个产品共享一个资源时,取较晚的到期)
|
||||
- permission:`readwrite` 覆盖 `read`(readwrite=1,否则 0)
|
||||
- basis:本系统客户一律 **只读**(permission 0),读写在 AccessGrant 手工授权时可用
|
||||
|
||||
## AList 侧落地(enforcement)
|
||||
|
||||
- 每个客户 = 1 个专属 AList Role(`name = customer-<username>`)
|
||||
- Role 的 `permission_scopes` = desired scopes 的绝对路径数组
|
||||
- 客户 User:`base_path = '/'`(**必须 `/`**,否则 JoinPath 双重前缀 → 全 403)
|
||||
- 客户 User:`permission = 16384`(bit 14 = Check path limit 置位 → 只能访问 role scope 内的路径)
|
||||
- `disabled` 由 status 控制:非 active → `disabled=true`
|
||||
|
||||
## 多文件夹授权正确做法(已实测)
|
||||
|
||||
- `base_path = "/"` + role scope 用**绝对路径**(如 `/Class/Forex`、`/Template/Music`)
|
||||
- 结果:客户只看到被授权路径,未授权挂载点(`/Book` 等)不泄露
|
||||
|
||||
## 到期与状态的关系
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| CustomerProduct 到期 | 该产品派生的路径从 scopes 移除(W2/W3 对账自动修)|
|
||||
| 整个 Customer status 变 non-active | 整户 disable + scopes 清空(W1)|
|
||||
| non-active → active 恢复 | 重算当前有效 scopes,**不恢复已过期产品** |
|
||||
|
||||
## 重要边界
|
||||
|
||||
- `Customers.status` 是账户级开关,**不替代** CustomerProduct 的到期。
|
||||
- 例:Customer = `expired`,其 Product A 仍 active 且未到期。恢复 `expired → active` 后 Product A 会自动回来;已过期的 Product B 不会回来。
|
||||
- 状态变化**不自动改** CustomerProducts(不批量改 CP.status、不改 expires_at、不删除 CP)。
|
||||
- AccessGrants 历史记录**不删除**;恢复时重算,不用旧 AccessGrant scope 直接恢复。
|
||||
+65
-46
@@ -1,4 +1,4 @@
|
||||
# Project State (Handoff) — 2026-08-22
|
||||
# Project State (Handoff) — 2026-08-23
|
||||
|
||||
> 交接文档:压缩/新会话后,按此文档续做。细节见各 doc。
|
||||
|
||||
@@ -40,37 +40,58 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
|
||||
- 表/字段/链接列 ID 全表:`docs/DATABASE_SCHEMA.md`(已更新为 7 表)
|
||||
- NocoDB API token(workspace 级,来自 nc_api_tokens 表 n8n 那行):在 `docs/API_INTEGRATION.md` / SETUP.md 有记(或查 NocoDB UI)
|
||||
|
||||
### 2. W1 客户开通 workflow — **已实测跑通** ✅
|
||||
- 文件:`n8n/W1-customer-provision.json`
|
||||
- 流程:Webhook → Extract customerId → **Load Env(step 2, 用 $env)** → Fetch 记录 → 判断 create/update → Create Role → Find Role Id → Create User(role:[roleId]) → Find User Id → Write Back
|
||||
- 关键:AList user/create 传 **空 role 数组会被拒**(自动填 guest→报 "admin or guest user can not be created")。必须先 Create Role 拿自增 id,再 Create User 带 `role:[id]`
|
||||
- **base_path 已硬编码 `'/'`**(不是 `/Class`,否则 JoinPath 双重前缀 → 403):commit 1ac1f17
|
||||
- **Loop guard**:新增 "Skip if already synced" IF 节点(sync_status=synced 时短路,不写回 NocoDB → 打破 webhook 循环);同时去掉 last_sync 写入(时间戳每次必变会破坏 guard)
|
||||
- Load Env 已同步为 `$env` 版本(process.env 在 Code 节点不可用):commit 58c2f57
|
||||
### 2. W1 客户开通 + Status Lifecycle — **实测跑通** ✅
|
||||
- 文件:`n8n/W1-customer-provision.json`(由 `n8n/w1-body.js` + 共享函数库经 `gen_w1.js` 生成)
|
||||
- 流程:Webhook → Extract customerId(同时透传 webhook 的 old/new rows)→ Load Env → **单 Code 节点 "Provision & Apply Lifecycle"**(含 login + status 分支 + write-back)
|
||||
- **Lifecycle matrix(5 状态,2026-08-23 定稿)**:
|
||||
- `active`(无 user)→ provision(建 role→建 user)+ recompute + enable
|
||||
- `active`(有 user)→ re-enable + recompute(不恢复旧 scopes)+ apply
|
||||
- `pending`/`suspended`/`expired`/`deleted`(有 user/role)→ disable AList user + 清空 role scopes(soft,保留 user/role/CP/history)
|
||||
- `pending`/`suspended`/`expired`/`deleted`(无 user)→ **no-op**(纯 NocoDB 标签,不建 AList user)
|
||||
- 所有 `X → active` resume → recompute(从当前有效 CP + expiry),**永不恢复旧 scopes**,**不自动 mutate CustomerProducts**
|
||||
- **幂等 + loop 防护**:见 `n8n/test_w1_lifecycle.js`(10 场景全过,覆盖完整 matrix + echo 短路 + sync_required 强制重算)
|
||||
- **Loop guard(2026-08-23 重写,核心)**:不再用 `sync_status==='synced'`,改用 **old/new field comparison + `sync_required` 标志**:
|
||||
- `businessChanged()` = 业务字段(status/username/password/display_name/email/notes)在 `previous_rows`→`rows` 间变化,**或** `sync_required` 置位,**或 after-insert(有 new 无 old)**
|
||||
- echo(只动了 alist_user_id/alist_role_id/sync_*/last_sync*)→ 立即 `skip_writeback_echo`,零 AList 变更、零 PATCH
|
||||
- **write-back 值比对守卫**:writeBack() 先 GET 当前记录,只 PATCH 值真会变的字段;值没变 → 无 PATCH → 无 webhook → 从物理上掐断 loop
|
||||
- 写回时始终清 `sync_required: false`
|
||||
- base_path 硬编码 `'/'`;空 role 数组先建 role 再建 user
|
||||
- ⚠ **AccessGrants 本次不物化**:不作为 lifecycle source-of-truth,保持现状,等 W4 统一设计(用户明确指示)
|
||||
|
||||
### 3. W2 CustomerProduct 权限同步 — **已改好,需重新 import 测试** ⏳
|
||||
- 文件:`n8n/W2-customerproduct-sync.json`
|
||||
- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants**(登录 AList→读 customer→读全部 active CP→读 Product 的 M2M Resources→**per-Resource MAX(expires_at)** 合并→读 AList role 现状→diff→算 newScopes) → Skip if no change → AList Update Role Scopes → Write Back CP Sync Status
|
||||
- **已修 bug(commit 36a6b3b)**:查询 active CP 的 where 语法从 `(customer,eq,${id})` → `(customer.Id,eq,${id})`。NocoDB link 字段用 display name 直接 eq 返回 0 条;必须用 `customer.Id` 子字段或外键列 `nc_jitu___Customers_id`
|
||||
- **已修 bug**:link API 返回 `{Id,name}` 不含 `alist_path` → 新增 resById 反查(先加载全量 Resources 按 Id 索引,再按 link 结果的 Id 反查 alist_path)
|
||||
- **Loop guard**:新增 "Skip if no change" IF + No-op 死端(scopes 无 diff 且已 synced 时短路);去掉 last_sync
|
||||
- ⚠️ 待用户重新 import + 测试
|
||||
### 3. W2 CustomerProduct 权限同步 — **实测跑通** ✅
|
||||
- 文件:`n8n/W2-customerproduct-sync.json`(`n8n/w2-body.js` + 共享库 → `gen_w2.js`)
|
||||
- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants & Apply**(单 Code 节点调共享 `calculateEffectiveGrants`)→ Skip if no change → No-op
|
||||
- customer link 为空时优雅跳过(`skipWriteBack:true`,不回写,补填后再触发)
|
||||
- expiry 边界 `<= today`(到期当天视为过期)
|
||||
|
||||
### 4. W3 Expiry Sync(全量对账)— **已实现,待 import + dry-run 测试** 🆕
|
||||
- 文件:`n8n/W3-expiry-sync.json`(Schedule `*/5` → 单 Code 节点"Reconcile & Apply")
|
||||
- 设计:`n8n/W3-DESIGN.md`;参考实现 `n8n/w3-compute.js`(自包含,read+write 都在 Code 内)
|
||||
- **核心逻辑**:对每个有 `alist_role_id` 的 customer → desired = 有效 CP 派生路径 ∪ 有效 AccessGrants 路径(**并集,防止整写 scopes 清掉 manual 授权**)→ 过期排除 → diff → `role/update` 整体重写 → 选项 disable
|
||||
- **Env 开关**:`ALIST_AUTO_DISABLE_ON_EXPIRE`(默认 false=只观察)、`W3_DRY_RUN`(true=只打印计划不写)
|
||||
- **disable 护栏**:仅当 `nowEmpty && status=active && currentPaths>0`(原本有 scope 现在空)才禁用;空 role 的新客户不动(否则刚开通没买产品就被禁,W2 不会重新启用)
|
||||
- **已本地验证**:`n8n/test_w3.js` mock 数据跑通 OFF/ON 两模式,断言全过(manual 授权保留、到期清除、disable 正确、空 role 客户不动)
|
||||
- **loop 防护**:只写 AList user disabled + role scopes;**不**翻 NocoDB Customers.status(Customers update webhook→W1 可能 re-provision/re-enable=loop)
|
||||
### 4. W3 Expiry Sync(全量对账)— **实测跑通** ✅
|
||||
- 文件:`n8n/W3-expiry-sync.json`(`n8n/w3-body.js` + 共享库 → `gen_w3.js`)
|
||||
- Schedule `*/5` → 单 Code 节点 "Reconcile & Apply"(逐客户调共享 `calculateEffectiveGrants` → diff → 应用)
|
||||
- 修过 Bearer 前缀 bug(AList 要裸 token)+ expiry 边界 `<= today`
|
||||
- Env:`W3_DRY_RUN`、`ALIST_AUTO_DISABLE_ON_EXPIRE`(默认 off)
|
||||
|
||||
### 5. 共享函数库(Step 3 核心重构)🆕
|
||||
- **`n8n/shared/effective-grants.js`** = 单一事实源:`calculateEffectiveGrants(customerId)` + `ncGet`/`alistReq`/`linkId`
|
||||
- 算法:desired = 有效 CP 派生路径 **∪** 有效 AccessGrants 路径;同 path 多 product 取 **MAX(expires_at)**;permission 取 readwrite 覆盖
|
||||
- expiry 边界 `<= today`;status 过滤非 active
|
||||
- **构建时内联**:`n8n/inline_shared.js` 的 `sharedSource()` 剥离 `module.exports`(`/* SHARED_EXPORT_BEGIN */` 之后),三个 `gen_w*.js` 把 body(`__SHARED_INLINE__` 占位)+ 共享源拼成 JSON
|
||||
- **W1/W2/W3 零复制算法**:三处都调同一个 `calculateEffectiveGrants`
|
||||
- **测试**(全过):`test_effective_grants.js`(共享算法)、`test_w3_refactored.js`(W3 回归)、`test_w1_lifecycle.js`(W1 lifecycle,10 场景全覆盖:pending/active/suspended/expired/deleted 全转换 + echo 短路 + sync_required 强制重算)
|
||||
|
||||
## 🔴 下一步(按序)
|
||||
1. **用户测试 W2**(import 覆盖 → 用例 2、3 → AList 前端实测客户登录看目录)→ 报错贴 execution 错误
|
||||
1.5. **W3 测试**:import `W3-expiry-sync.json` → 设 `W3_DRY_RUN=true` 跑一次看计划 → 造过期 CP 观察 scope 移除 → 开关 auto-disable 做 M3(详见 W3-DESIGN 里程碑)
|
||||
2. 验证通过后:**expiry scheduler**(每 5 分钟对账:全量 diff NocoDB↔AList,到期的移除 scope)—— 规格 §26/§37
|
||||
3. 其余 workflow(W3-W6):Product/Resource 变更 → 重算受影响 customer;手动 sync;日志;通知
|
||||
4. 本地 git 留档 → 验证稳定后推 Gitea(git.hoelee.com)
|
||||
|
||||
> 功能已全部实现并通过本地测试(用户已确认「已测试功能正常」)。剩余事项如下:
|
||||
|
||||
1. **reimport W1 到 n8n 并做真实验收**(W2/W3 也重新生成过,可一并覆盖):
|
||||
- 改 `Customers.status` 一次 → 只产生**一次**真正 W1 execution(`active_provision` 或 `deactivate`),其后 echo 全是 `skip_writeback_echo`
|
||||
- 验证 W1 execution 的 `desiredPaths` 非空、AList role `permission_scopes` 落对
|
||||
2. **(Step 4) W4 Full Reconciliation**:NocoDB desired vs AList actual 全量 drift 修复 + reconciliation 日志(W3 已具备基础,W4 补每日全量 + 日志)
|
||||
3. **(待设计) AccessGrants 物化**:当前 W2/W3 不写 AccessGrants 表,不作为 source-of-truth。W4 时统一设计 reconciliation/materialization 策略
|
||||
4. 密码回写问题(客户自改 AList 密码 → NocoDB plaintext 失真):当前不强制回写(用户已选方案 C),必要时再议
|
||||
5. 本地 git 已留档并推 Gitea(`git.hoelee.com/hoelee/alist-serve-resource`,凭据已脱敏)
|
||||
|
||||
> ⚠️ **Status 语义已定稿(不再待决策)**:5 个值 `pending/active/suspended/expired/deleted`,
|
||||
> account-level 整户开关,**不进** permission calc。详见 `docs/ARCHITECTURE.md`、`docs/PERMISSIONS.md`、`docs/DATABASE_SCHEMA.md`。
|
||||
|
||||
## 🐛 已沉淀的坑(勿再踩)
|
||||
- **AList**:
|
||||
@@ -92,32 +113,30 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
|
||||
- Code 节点内 `$env` ✅(**用户实测可用**,如 `$env.NOCODB_URL`)
|
||||
- HTTP 节点表达式字段里 `$env` / `process.env` ❌ 都不行
|
||||
- 模式:step 2 放 "Load Env" Code 节点输出全部 env **并透传 payload 字段**,下游 HTTP 用 `$('Load Env').item.json.VAR`
|
||||
- step 2 已验证模板:
|
||||
```js
|
||||
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 || '',
|
||||
} };
|
||||
```
|
||||
- `gen_w*.js` 里的 Load Env 节点已含所有表 env + `previousRow`/`newRow` 透传(W1);直接看 `n8n/gen_w1.js` 最新版为准
|
||||
|
||||
- **Webhook write-back loop(2026-08-23 已修)**:
|
||||
- 症状:n8n 写回 NocoDB(sync_status 等)→ 触发 webhook → 又写回 → 无限循环;且 `sync_status==='synced'` 做 guard 不可靠(suspend 分支也写 synced)
|
||||
- 正确做法:**old/new field comparison**。NocoDB v2 webhook payload `data.rows`(改后)+ `data.previous_rows`(改前)。只有业务字段(status/username/password/display_name/email/notes)变化才是 admin 变更;只动了 alist_user_id/alist_role_id/sync_*/last_sync* = echo → 短路
|
||||
- 加 `sync_required`(Checkbox)作为强制重算开关:admin 需要强制 provision 时勾选;写回时始终清 false
|
||||
- **writeBack 值比对守卫**:PATCH 前先 GET 当前值,只写真会变的字段;值没变 → 不发 PATCH → 不触发 webhook(物理层面掐断 loop)
|
||||
- extract-id 节点必须透传 `previousRow`/`newRow`(经 Load Env 转发)到 lifecycle Code 节点
|
||||
|
||||
## 📁 项目结构
|
||||
```
|
||||
alist-serve-resource/
|
||||
├── docker-compose.yml / .env.example / .gitignore
|
||||
├── n8n/ W1-customer-provision.json · W2-customerproduct-sync.json · SETUP.md · W2-DESIGN.md · w2-compute.js
|
||||
├── n8n/
|
||||
│ W1/W2/W3-*.json (generated) · w1/w2/w3-body.js (runtime logic)
|
||||
│ shared/effective-grants.js (single source of truth)
|
||||
│ gen_w1/w2/w3.js + inline_shared.js (build-time inlining)
|
||||
│ test_effective_grants.js · test_w1_lifecycle.js · test_w3_refactored.js
|
||||
│ SETUP.md · W2-DESIGN.md · W3-DESIGN.md
|
||||
├── scripts/ create_nocodb_schema.py · healthcheck.sh
|
||||
└── docs/ API_INTEGRATION.md · DATABASE_SCHEMA.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md
|
||||
└── docs/ API_INTEGRATION.md · ARCHITECTURE.md · PERMISSIONS.md · ADMIN_GUIDE.md · DATABASE_SCHEMA.md · N8N_WORKFLOWS.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md
|
||||
```
|
||||
|
||||
## 待确认的设计点
|
||||
1. W2 当前不写 AccessGrants 表 —— 是否在验证后补物化写入?(避免 AccessGrant→n8n loop 的前提下,由 W2 单向写)
|
||||
1. **AccessGrants 物化**(已定:本次不物化,不作为 source-of-truth)—— W4 时统一设计 reconciliation/materialization 策略,需避免 AccessGrant→n8n loop
|
||||
2. 密码:客户可自改 AList 密码 → NocoDB plaintext 会失真;当前不强制回写(用户已选方案 C)
|
||||
3. 对账/到期只靠 n8n 定时扫描(AList 无 expiry 概念)—— 已确认
|
||||
+15
-572
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
// Builds n8n/W1-customer-provision.json — Customer webhook with status lifecycle.
|
||||
// Single source of truth: shared/effective-grants.js (inlined) + w1-body.js.
|
||||
// Run: node n8n/gen_w1.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sharedSource } = require('./inline_shared');
|
||||
|
||||
let body = fs.readFileSync(path.join(__dirname, 'w1-body.js'), 'utf8');
|
||||
body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource());
|
||||
|
||||
const workflow = {
|
||||
name: 'W1 - Customer Provision & Status Lifecycle',
|
||||
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 ?? {};\n// NocoDB v2: data.rows = post-update rows, data.previous_rows = pre-update rows.\nconst newRows = data.rows ?? data.rows_new ?? [];\nconst oldRows = data.previous_rows ?? data.rows_old ?? [];\nconst newRow = newRows[0] ?? {};\nconst oldRow = oldRows[0] ?? {};\nconst id = newRow.Id ?? oldRow.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '', previousRow: oldRow, newRow } };\n"
|
||||
},
|
||||
id: 'extract-id',
|
||||
name: 'Extract Customer Record Id',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// pass-through env + webhook fields (custom env vars readable ONLY via $env here)\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n previousRow: prev.previousRow,\n newRow: prev.newRow,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n"
|
||||
},
|
||||
id: 'load-env',
|
||||
name: 'Load Env',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
},
|
||||
{
|
||||
parameters: { jsCode: body },
|
||||
id: 'lifecycle',
|
||||
name: 'Provision & Apply Lifecycle',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [440, 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 }]] },
|
||||
'Load Env': { main: [[{ node: 'Provision & Apply Lifecycle', type: 'main', index: 0 }]] }
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
pinData: {}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'W1-customer-provision.json'), JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log('wrote W1-customer-provision.json (' + body.length + ' chars body)');
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// Builds n8n/W2-customerproduct-sync.json — the CustomerProduct webhook.
|
||||
// Single source of truth: shared/effective-grants.js (inlined) + w2-body.js.
|
||||
// Run: node n8n/gen_w2.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sharedSource } = require('./inline_shared');
|
||||
|
||||
let body = fs.readFileSync(path.join(__dirname, 'w2-body.js'), 'utf8');
|
||||
body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource());
|
||||
|
||||
const workflow = {
|
||||
name: 'W2 - CustomerProduct Sync (Effective Grants)',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'alist-customerproduct-sync',
|
||||
responseMode: 'onReceived',
|
||||
options: {}
|
||||
},
|
||||
id: 'trigger-webhook',
|
||||
name: 'NocoDB: CustomerProduct Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
webhookId: 'customerproduct-sync'
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.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 CustomerProduct Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n"
|
||||
},
|
||||
id: 'extract-id',
|
||||
name: 'Extract CustomerProduct Id',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// pass-through env + webhook fields (Code node reads $env reliably only here)\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 NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n"
|
||||
},
|
||||
id: 'load-env',
|
||||
name: 'Load Env',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
},
|
||||
{
|
||||
parameters: { jsCode: body },
|
||||
id: 'compute-apply',
|
||||
name: 'Compute Effective Grants & Apply',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [440, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeName: 'boolean', ignoreCase: true },
|
||||
conditions: [
|
||||
{
|
||||
id: 'cond-skip',
|
||||
leftValue: "={{ $('Compute Effective Grants & Apply').item.json.skipWriteBack }}",
|
||||
rightValue: true,
|
||||
operator: { type: 'boolean', operation: 'equals', singleValue: true, rightType: 'boolean' }
|
||||
}
|
||||
],
|
||||
combinator: 'and'
|
||||
},
|
||||
options: {}
|
||||
},
|
||||
id: 'if-skip',
|
||||
name: 'Skip if no change',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 2,
|
||||
position: [660, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "return { json: { skipped: true, customerId: $('Compute Effective Grants & Apply').item.json.customerId } };"
|
||||
},
|
||||
id: 'skip-echo',
|
||||
name: 'Skip Echo (No-op)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [660, -200]
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'NocoDB: CustomerProduct Webhook': { main: [[{ node: 'Extract CustomerProduct Id', type: 'main', index: 0 }]] },
|
||||
'Extract CustomerProduct Id': { main: [[{ node: 'Load Env', type: 'main', index: 0 }]] },
|
||||
'Load Env': { main: [[{ node: 'Compute Effective Grants & Apply', type: 'main', index: 0 }]] },
|
||||
'Compute Effective Grants & Apply': { main: [[{ node: 'Skip if no change', type: 'main', index: 0 }]] },
|
||||
'Skip if no change': {
|
||||
main: [
|
||||
[{ node: 'Skip Echo (No-op)', type: 'main', index: 0 }],
|
||||
[] // no-change path terminates silently (nothing to do)
|
||||
]
|
||||
},
|
||||
'Skip Echo (No-op)': { main: [] }
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
pinData: {}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'W2-customerproduct-sync.json'), JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log('wrote W2-customerproduct-sync.json (' + body.length + ' chars body)');
|
||||
+10
-14
@@ -1,19 +1,19 @@
|
||||
// Builds n8n/W3-expiry-sync.json from w3-compute.js (single source of truth).
|
||||
// Builds n8n/W3-expiry-sync.json — the 5-min expiry sweep.
|
||||
// Single source of truth: shared/effective-grants.js (inlined) + w3-body.js.
|
||||
// Run: node n8n/gen_w3.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jsCode = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8');
|
||||
const { sharedSource } = require('./inline_shared');
|
||||
|
||||
let body = fs.readFileSync(path.join(__dirname, 'w3-body.js'), 'utf8');
|
||||
body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource());
|
||||
|
||||
const workflow = {
|
||||
name: 'W3 - Expiry Sync (AList Role Reconciliation)',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{ field: 'minutes', minutesInterval: 5 }
|
||||
]
|
||||
}
|
||||
rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] }
|
||||
},
|
||||
id: 'trigger-schedule',
|
||||
name: 'Every 5 minutes',
|
||||
@@ -22,7 +22,7 @@ const workflow = {
|
||||
position: [0, 0]
|
||||
},
|
||||
{
|
||||
parameters: { jsCode },
|
||||
parameters: { jsCode: body },
|
||||
id: 'reconcile',
|
||||
name: 'Reconcile & Apply (Expiry Sweep)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
@@ -32,11 +32,7 @@ const workflow = {
|
||||
],
|
||||
connections: {
|
||||
'Every 5 minutes': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Reconcile & Apply (Expiry Sweep)', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
main: [[{ node: 'Reconcile & Apply (Expiry Sweep)', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
@@ -44,4 +40,4 @@ const workflow = {
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'W3-expiry-sync.json'), JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log('wrote W3-expiry-sync.json (' + jsCode.length + ' chars jsCode)');
|
||||
console.log('wrote W3-expiry-sync.json (' + body.length + ' chars body)');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Shared build helper: inline shared/effective-grants.js into a workflow's
|
||||
// Code node body, stripping the module.exports shim. All gen_*.js scripts use
|
||||
// this so the "single source of truth" file is what actually ships.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SHARED_PATH = path.join(__dirname, 'shared', 'effective-grants.js');
|
||||
const EXPORT_MARKER = '/* SHARED_EXPORT_BEGIN */';
|
||||
|
||||
// Returns the shared library source WITHOUT the module.exports block — a
|
||||
// plain script ready to prepend to (or include in) a Code node's jsCode.
|
||||
function sharedSource() {
|
||||
const src = fs.readFileSync(SHARED_PATH, 'utf8');
|
||||
const idx = src.indexOf(EXPORT_MARKER);
|
||||
if (idx === -1) throw new Error('SHARED_EXPORT_BEGIN marker not found in shared source');
|
||||
return src.slice(0, idx).replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
module.exports = { sharedSource, SHARED_PATH };
|
||||
@@ -0,0 +1,127 @@
|
||||
// ============================================================================
|
||||
// SHARED: calculateEffectiveGrants — single source of truth for "what scopes
|
||||
// should customer X have right now", reused by W1 (active resume), W2
|
||||
// (CustomerProduct sync) and W3 (expiry sweep).
|
||||
//
|
||||
// This file is INLINED into each workflow's Code node at build time (see
|
||||
// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:
|
||||
// no require/import, no process.exit, no top-level return outside functions.
|
||||
//
|
||||
// Config is read via $env (the ONLY reliable channel in n8n Code nodes for
|
||||
// custom env vars — process.env is unreliable there). The caller passes a
|
||||
// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
// --- helpers (depend on ctx, so they are functions not top-level consts) ---
|
||||
|
||||
function ncUrl(ctx, path) {
|
||||
return ctx.$env.NOCODB_URL + path;
|
||||
}
|
||||
|
||||
async function ncGet(ctx, path) {
|
||||
return await ctx.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: ncUrl(ctx, path),
|
||||
headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function alistReq(ctx, method, path, body, token) {
|
||||
// NOTE: AList expects the RAW token in Authorization (no "Bearer " prefix).
|
||||
return await ctx.helpers.httpRequest({
|
||||
method,
|
||||
url: ctx.$env.ALIST_URL + path,
|
||||
headers: token ? { Authorization: token } : {},
|
||||
body,
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
|
||||
// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on
|
||||
// whether the API was asked to expand them. Normalise to a single id or null.
|
||||
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;
|
||||
}
|
||||
|
||||
// --- core algorithm --------------------------------------------------------
|
||||
//
|
||||
// desired scopes for a customer = union of:
|
||||
// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct
|
||||
// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant
|
||||
//
|
||||
// For (a) the SAME resource coming from MULTIPLE products keeps the MAX
|
||||
// expires_at, exactly matching the "two products share a resource" semantics
|
||||
// the user tested. Expiry boundary is `<= today` (expiring today is expired).
|
||||
|
||||
async function calculateEffectiveGrants(ctx, customerId) {
|
||||
const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;
|
||||
const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;
|
||||
const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;
|
||||
const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
|
||||
const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
|
||||
// index all Resources by Id -> alist_path (cheap, one query)
|
||||
const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];
|
||||
const resPath = {};
|
||||
allRes.forEach(r => { resPath[r.Id] = r.alist_path; });
|
||||
|
||||
const desired = {}; // path -> { expires, permission }
|
||||
|
||||
// (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on
|
||||
// the same path.
|
||||
// NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always
|
||||
// returns 0 rows. Must use the `customer.Id` sub-field syntax (or the
|
||||
// foreign-key column `nc_jitu___Customers_id`).
|
||||
const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];
|
||||
for (const c of cps) {
|
||||
if (c.status && c.status !== 'active') continue;
|
||||
if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)
|
||||
const pid = linkId(c.product);
|
||||
if (!pid) continue;
|
||||
const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
|
||||
const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)
|
||||
for (const r of res) {
|
||||
const p = resPath[r.Id];
|
||||
if (!p) continue;
|
||||
const cur = desired[p];
|
||||
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
|
||||
desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) AccessGrants -> Resources (manual / materialised direct grants)
|
||||
const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];
|
||||
for (const g of grants) {
|
||||
if (g.status && g.status !== 'active') continue;
|
||||
if (g.expires_at && String(g.expires_at) <= today) continue;
|
||||
const p = resPath[linkId(g.resource)];
|
||||
if (!p) continue;
|
||||
const exp = g.expires_at;
|
||||
const cur = desired[p];
|
||||
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
|
||||
desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const desiredPaths = Object.keys(desired).sort();
|
||||
return {
|
||||
customerId,
|
||||
desired, // { path: {expires, permission} }
|
||||
desiredPaths, // sorted array of paths
|
||||
newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),
|
||||
};
|
||||
}
|
||||
|
||||
// --- export shim: kept only when unit-tested, stripped when inlined -------
|
||||
/* SHARED_EXPORT_BEGIN */
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { calculateEffectiveGrants, ncGet, alistReq, linkId, ncUrl };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Unit test for shared/effective-grants.js — validate calculateEffectiveGrants
|
||||
// against mocked NocoDB/AList, exercising the tricky cases:
|
||||
// * two products share a resource -> MAX expiry wins
|
||||
// * expired CP (expires_at <= today) excluded
|
||||
// * non-active CP excluded
|
||||
// * manual AccessGrant unioned in, and MAX expiry across sources
|
||||
// * permission readwrite override
|
||||
// Run: node n8n/test_effective_grants.js
|
||||
const path = require('path');
|
||||
const { calculateEffectiveGrants, linkId } = require(path.join(__dirname, 'shared', 'effective-grants.js'));
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10); // real today for expiry edge
|
||||
|
||||
// ---------------- mock data ----------------
|
||||
const RESOURCES = [
|
||||
{ Id: 1, alist_path: '/Class/Python/01' },
|
||||
{ Id: 2, alist_path: '/Class/Python/02' },
|
||||
{ Id: 3, alist_path: '/Class/Health' },
|
||||
{ Id: 4, alist_path: '/Template/Music' },
|
||||
];
|
||||
// product -> resources (M2M link)
|
||||
const PRODUCT_RES = {
|
||||
1: [{ Id: 1 }, { Id: 2 }], // Product A: Python 01+02
|
||||
2: [{ Id: 3 }], // Product B: Health
|
||||
3: [{ Id: 4 }], // Product C: Music
|
||||
4: [{ Id: 1 }], // Product D: shares Python 01 with A
|
||||
};
|
||||
const CUSTOMERPRODUCTS = [
|
||||
{ Id: 1, customer: 1, product: 1, status: 'active', expires_at: '2099-01-01' },
|
||||
{ Id: 2, customer: 1, product: 2, status: 'active', expires_at: '2099-06-01' },
|
||||
{ Id: 3, customer: 1, product: 4, status: 'active', expires_at: '2098-12-01' }, // shares path /Class/Python/01
|
||||
{ Id: 4, customer: 1, product: 3, status: 'active', expires_at: TODAY }, // EXPIRING TODAY -> excluded
|
||||
{ Id: 5, customer: 1, product: 3, status: 'revoked', expires_at: '2099-01-01' }, // revoked -> excluded
|
||||
];
|
||||
const ACCESSGRANTS = [
|
||||
{ Id: 1, customer: 1, resource: 4, status: 'active', expires_at: '2099-03-01', permission: 'readwrite' },
|
||||
{ Id: 2, customer: 1, resource: 3, status: 'active', expires_at: '2098-01-01', permission: 'read' },
|
||||
{ Id: 3, customer: 1, resource: 2, status: 'expired', expires_at: '2020-01-01' }, // expired grant excluded
|
||||
];
|
||||
|
||||
// mock ctx implementing ctx.$env + ctx.helpers.httpRequest
|
||||
function makeCtx() {
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
$env: {
|
||||
NOCODB_URL: 'https://nocodb.test',
|
||||
NOCODB_TOKEN: 'test-token',
|
||||
ALIST_URL: 'https://alist.test',
|
||||
NOCODB_TABLE_CUSTOMERS: 'tc',
|
||||
NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc',
|
||||
NOCODB_TABLE_PRODUCTS: 'tp',
|
||||
NOCODB_TABLE_RESOURCES: 'tr',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'tag',
|
||||
NOCODB_LINK_PROD_RESOURCES: 'lnk',
|
||||
NOCODB_TABLE_PURCHASES: 'tpur',
|
||||
},
|
||||
helpers: {
|
||||
httpRequest: async (opts) => {
|
||||
calls.push(opts.url);
|
||||
const u = opts.url;
|
||||
// Resources list
|
||||
if (u.includes('/tc/records') && u.includes('limit=10000')) {
|
||||
if (u.includes('/tr/records')) return { list: RESOURCES };
|
||||
}
|
||||
if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES };
|
||||
// CustomerProducts where customer eq
|
||||
if (u.includes('/tcpc/records?where=(customer.Id,eq,')) {
|
||||
return { list: CUSTOMERPRODUCTS };
|
||||
}
|
||||
// AccessGrants where customer eq
|
||||
if (u.includes('/tag/records?where=(customer.Id,eq,')) {
|
||||
return { list: ACCESSGRANTS };
|
||||
}
|
||||
// Product M2M link
|
||||
const m = u.match(/\/tp\/links\/lnk\/records\/(\d+)/);
|
||||
if (m) {
|
||||
return { list: PRODUCT_RES[Number(m[1])] || [] };
|
||||
}
|
||||
throw new Error('unexpected mock URL: ' + u);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, calls };
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function assert(name, cond, detail) {
|
||||
if (cond) { console.log('PASS ' + name); }
|
||||
else { failures++; console.log('FAIL ' + name + (detail ? ' -> ' + JSON.stringify(detail) : '')); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { ctx } = makeCtx();
|
||||
const r = await calculateEffectiveGrants(ctx, 1);
|
||||
|
||||
// desiredPaths expected:
|
||||
// Product A (2099-01-01): /Class/Python/01, /Class/Python/02
|
||||
// Product B (2099-06-01): /Class/Health
|
||||
// Product D (2098-12-01): /Class/Python/01 (max(2099-01-01, 2098-12-01) = 2099-01-01)
|
||||
// Product C expiring TODAY -> excluded -> /Template/Music NOT from CP
|
||||
// grant 1 (readwrite, 2099-03-01): /Template/Music
|
||||
// grant 2 (read, 2098-01-01): /Class/Health (already present, max(2099-06-01) wins)
|
||||
// grant 3 expired -> excluded
|
||||
// => final paths: Python/01, Python/02, Health, Music (4 paths)
|
||||
const paths = r.desiredPaths;
|
||||
assert('4 desired paths', paths.length === 4, paths);
|
||||
assert('has Python/01', paths.includes('/Class/Python/01'), paths);
|
||||
assert('has Python/02', paths.includes('/Class/Python/02'), paths);
|
||||
assert('has Health', paths.includes('/Class/Health'), paths);
|
||||
assert('has Music (via manual grant)', paths.includes('/Template/Music'), paths);
|
||||
|
||||
// expiry MAX across shared resource
|
||||
assert('Python/01 expiry = 2099-01-01 (MAX of A 2099-01-01 and D 2098-12-01)',
|
||||
r.desired['/Class/Python/01'].expires === '2099-01-01', r.desired['/Class/Python/01']);
|
||||
// Health expiry = MAX(CP B 2099-06-01, grant 2098-01-01) = 2099-06-01
|
||||
assert('Health expiry = 2099-06-01 (MAX of product vs grant)',
|
||||
r.desired['/Class/Health'].expires === '2099-06-01', r.desired['/Class/Health']);
|
||||
// Music expiry = 2099-03-01 from manual grant, permission readwrite -> 1
|
||||
assert('Music permission = readwrite (1)',
|
||||
r.desired['/Template/Music'].permission === 1, r.desired['/Template/Music']);
|
||||
// Python/01 permission default 0
|
||||
assert('Python/01 permission = 0 (default)',
|
||||
r.desired['/Class/Python/01'].permission === 0, r.desired['/Class/Python/01']);
|
||||
|
||||
// newScopes shape
|
||||
assert('newScopes length 4', r.newScopes.length === 4, r.newScopes);
|
||||
assert('newScopes are {path,permission}', r.newScopes.every(s => typeof s.path === 'string' && typeof s.permission === 'number'), r.newScopes);
|
||||
|
||||
// linkId normalisation
|
||||
assert('linkId object', linkId({ Id: 7 }) === 7);
|
||||
assert('linkId array', linkId([{ Id: 7 }]) === 7);
|
||||
assert('linkId null', linkId(null) === null);
|
||||
assert('linkId number', linkId(7) === 7);
|
||||
|
||||
console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} TEST(S) FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
main();
|
||||
@@ -0,0 +1,240 @@
|
||||
// Idempotency + lifecycle + SELF-WRITE-BACK SUPPRESSION tests for W1 (Step 3).
|
||||
// Loads the ACTUAL inlined code from W1-customer-provision.json and runs it
|
||||
// against a mock ctx, driving a customer through the full 5-status lifecycle:
|
||||
// pending -> active -> suspended -> active -> expired -> active -> deleted -> active
|
||||
// and asserting converged AList end-state each time, PLUS:
|
||||
// - a business change (admin flips status) processes exactly once
|
||||
// - our own write-back echo (bookkeeping-only delta) is SKIPPED and does NOT
|
||||
// PATCH again (this is the loop fix).
|
||||
// - resume (X -> active) recomputes scopes, never restores stale paths.
|
||||
// - pending/expired/deleted with NO user yet -> no-op (no AList user created).
|
||||
// Run: node n8n/test_w1_lifecycle.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W1-customer-provision.json'), 'utf8'));
|
||||
const code = workflow.nodes.find(n => n.name === 'Provision & Apply Lifecycle').parameters.jsCode;
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ---- mutable mock state (represents AList + NocoDB) ----
|
||||
const state = {
|
||||
// NocoDB customer record (the fields W1 reads)
|
||||
customer: { Id: 1, username: 'alice', password: 'pw', status: 'pending', alist_user_id: null, alist_role_id: null, sync_status: 'pending', sync_required: false, last_sync_error: null },
|
||||
// AList
|
||||
users: [], // {id, username, disabled, role}
|
||||
roles: [], // {id, name, permission_scopes}
|
||||
nextUserId: 1000,
|
||||
nextRoleId: 2000,
|
||||
// NocoDB CPs (for calculateEffectiveGrants)
|
||||
cps: [
|
||||
{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' },
|
||||
],
|
||||
grants: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }],
|
||||
resources: [
|
||||
{ Id: 1, alist_path: '/Class/Python/01' },
|
||||
{ Id: 2, alist_path: '/Class/Health' },
|
||||
{ Id: 3, alist_path: '/Template/Music' },
|
||||
],
|
||||
productRes: { 2: [{ Id: 2 }] },
|
||||
// record write-back PATCHes
|
||||
patched: [],
|
||||
};
|
||||
|
||||
function mockHttp(opts) {
|
||||
const u = opts.url;
|
||||
const m = opts.method || 'GET';
|
||||
const body = opts.body || {};
|
||||
|
||||
if (u.endsWith('/api/auth/login')) return { data: { token: 'TOKEN' } };
|
||||
|
||||
// NocoDB customer GET
|
||||
const cget = u.match(/\/tc\/records\/(\d+)$/);
|
||||
if (cget && m === 'GET') return state.customer;
|
||||
|
||||
// NocoDB customer PATCH (write-back)
|
||||
if (u.includes('/tc/records') && m === 'PATCH') {
|
||||
const fields = body[0] || body;
|
||||
state.patched.push(Object.assign({}, fields));
|
||||
Object.assign(state.customer, fields);
|
||||
return { code: 200 };
|
||||
}
|
||||
|
||||
// resources list
|
||||
if (u.includes('/tr/records?limit=10000')) return { list: state.resources };
|
||||
|
||||
// CP where customer eq
|
||||
const cpm = u.match(/tcpc\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (cpm) return { list: state.cps.filter(c => c.customer === Number(cpm[1])) };
|
||||
|
||||
// grants where customer eq
|
||||
const gm = u.match(/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (gm) return { list: state.grants.filter(g => g.customer === Number(gm[1])) };
|
||||
|
||||
// product M2M
|
||||
const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/);
|
||||
if (pm) return { list: state.productRes[Number(pm[1])] || [] };
|
||||
|
||||
// role create
|
||||
if (u.endsWith('/api/admin/role/create')) {
|
||||
const r = { id: state.nextRoleId++, name: body.name, description: body.description, permission_scopes: body.permission_scopes || [] };
|
||||
state.roles.push(r);
|
||||
return { data: r };
|
||||
}
|
||||
if (u.endsWith('/api/admin/role/list?page=1&per_page=100')) return { data: { content: state.roles } };
|
||||
const rg = u.match(/role\/get\?id=(\d+)/);
|
||||
if (rg) {
|
||||
const r = state.roles.find(x => x.id === Number(rg[1]));
|
||||
if (!r) return { data: {} };
|
||||
return { data: { name: r.name, description: r.description, permission_scopes: r.permission_scopes } };
|
||||
}
|
||||
if (u.endsWith('/api/admin/role/update')) {
|
||||
const r = state.roles.find(x => x.id === body.id);
|
||||
if (r) r.permission_scopes = body.permission_scopes;
|
||||
return { code: 200 };
|
||||
}
|
||||
if (u.endsWith('/api/admin/user/create')) {
|
||||
const us = { id: state.nextUserId++, username: body.username, disabled: body.disabled ?? false, role: body.role };
|
||||
state.users.push(us);
|
||||
return { data: us };
|
||||
}
|
||||
const ug = u.match(/user\/get\?id=(\d+)/);
|
||||
if (ug) {
|
||||
const us = state.users.find(x => x.id === Number(ug[1]));
|
||||
return { data: us || {} };
|
||||
}
|
||||
if (u.endsWith('/api/admin/user/list?page=1&per_page=100')) return { data: { content: state.users } };
|
||||
if (u.endsWith('/api/admin/user/update')) {
|
||||
const us = state.users.find(x => x.id === body.id);
|
||||
if (us) {
|
||||
if (body.disabled !== undefined) us.disabled = body.disabled;
|
||||
if (body.role !== undefined) us.role = body.role;
|
||||
}
|
||||
return { code: 200 };
|
||||
}
|
||||
|
||||
throw new Error('unexpected: ' + m + ' ' + u);
|
||||
}
|
||||
|
||||
async function runW1(customerId, { status, oldRow, newRow, syncRequired } = {}) {
|
||||
if (status !== undefined) state.customer.status = status;
|
||||
if (syncRequired !== undefined) state.customer.sync_required = syncRequired;
|
||||
state.customer.alist_user_id = state.customer.alist_user_id ?? null;
|
||||
state.customer.alist_role_id = state.customer.alist_role_id ?? null;
|
||||
|
||||
const prevRow = oldRow || { ...state.customer };
|
||||
const nRow = newRow || { ...state.customer };
|
||||
|
||||
const sandbox = {
|
||||
$env: {
|
||||
NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al',
|
||||
NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc',
|
||||
NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_LINK_PROD_RESOURCES: 'lnk',
|
||||
ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p',
|
||||
},
|
||||
console,
|
||||
};
|
||||
sandbox.helpers = { httpRequest: mockHttp };
|
||||
const wrapped = `(async function(){\nconst $json = { customerId: ${customerId}, previousRow: ${JSON.stringify(prevRow)}, newRow: ${JSON.stringify(nRow)} };\n${code}\n})`;
|
||||
const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w1-body.js' });
|
||||
return await fn();
|
||||
}
|
||||
|
||||
let fail = 0;
|
||||
const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } };
|
||||
|
||||
function roleScopes(roleId) {
|
||||
const r = state.roles.find(x => x.id === roleId);
|
||||
return (r?.permission_scopes || []).map(s => s.path).sort();
|
||||
}
|
||||
function userById(id) { return state.users.find(x => x.id === id); }
|
||||
|
||||
async function main() {
|
||||
// --- 1. pending with NO user yet -> no-op (no AList user created) ---
|
||||
console.log('\n[1] pending (no user yet, after-insert) -> no-op');
|
||||
let r = await runW1(1, { status: 'pending', oldRow: {}, newRow: { Id: 1, username: 'alice', status: 'pending', sync_status: 'pending' } });
|
||||
A('lifecycle noop_not_provisioned', r.json.lifecycle === 'noop_not_provisioned', r.json);
|
||||
A('no user created', state.users.length === 0, state.users);
|
||||
A('no role created', state.roles.length === 0, state.roles);
|
||||
|
||||
// --- 2. pending -> active (business change) -> provision ---
|
||||
console.log('\n[2] pending -> active (provision)');
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'pending' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user created', state.users.length === 1, state.users);
|
||||
A('role created', state.roles.length === 1, state.roles);
|
||||
A('user enabled', userById(state.users[0].id).disabled === false, state.users[0]);
|
||||
const uid1 = state.users[0].id;
|
||||
const rid1 = state.roles[0].id;
|
||||
A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
A('wrote ids back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer);
|
||||
const patchesAfter2 = state.patched.length;
|
||||
|
||||
// --- 3. write-back echo -> skip, NO new PATCH ---
|
||||
console.log('\n[3] write-back echo (bookkeeping-only)');
|
||||
r = await runW1(1, { oldRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1 }, newRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1, sync_status: 'synced', sync_required: false } });
|
||||
A('echo -> skip_writeback_echo', r.json.lifecycle === 'skip_writeback_echo', r.json);
|
||||
A('NO new PATCH', state.patched.length === patchesAfter2, { before: patchesAfter2, after: state.patched.length });
|
||||
|
||||
// --- 4. active -> suspended -> disable + clear ---
|
||||
console.log('\n[4] active -> suspended');
|
||||
r = await runW1(1, { status: 'suspended', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'suspended' } });
|
||||
A('lifecycle deactivate', r.json.lifecycle === 'deactivate', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
A('user NOT deleted (soft)', state.users.length === 1, state.users);
|
||||
|
||||
// --- 5. suspended -> active (resume, recompute not restore stale) ---
|
||||
console.log('\n[5] suspended -> active (recompute)');
|
||||
state.cps = []; // remove CPs -> only manual grant remains
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'suspended' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision (resume)', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes recomputed = only Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 6. active -> expired -> disable + clear ---
|
||||
console.log('\n[6] active -> expired');
|
||||
r = await runW1(1, { status: 'expired', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'expired' } });
|
||||
A('lifecycle deactivate', r.json.lifecycle === 'deactivate', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
|
||||
// --- 7. expired -> active (recompute; only non-expired products) ---
|
||||
console.log('\n[7] expired -> active (restore only valid)');
|
||||
// make product 2 non-expired + add an EXPIRED CP that must NOT restore
|
||||
state.cps = [
|
||||
{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' },
|
||||
{ Id: 8, customer: 1, product: 2, status: 'active', expires_at: '2020-01-01' }, // expired -> should not add anything new (product 2 already yields Health)
|
||||
];
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'expired' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes = Health + Music (expired CP ignored)', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 8. active -> deleted -> disable + clear (soft delete) ---
|
||||
console.log('\n[8] active -> deleted (soft)');
|
||||
r = await runW1(1, { status: 'deleted', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'deleted' } });
|
||||
A('lifecycle deactivate', r.json.lifecycle === 'deactivate', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
A('user retained (soft)', state.users.length === 1, state.users);
|
||||
|
||||
// --- 9. deleted -> active (recompute) ---
|
||||
console.log('\n[9] deleted -> active (restore)');
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'deleted' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes recomputed', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 10. sync_required flag forces reprocess ---
|
||||
console.log('\n[10] sync_required forces reprocess');
|
||||
r = await runW1(1, { status: 'active', syncRequired: true, oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_required: true } });
|
||||
A('lifecycle active_provision (forced)', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user still enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
|
||||
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
main().then(() => {}).catch(e => { console.error('RUN ERROR:', e); process.exit(1); });
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
// Standalone correctness test for w3-compute.js logic.
|
||||
// Mocks this.helpers.httpRequest + $env, runs the exact n8n Code-node code,
|
||||
// and asserts desired-union / diff / disable decisions.
|
||||
// Run: node n8n/test_w3.js (from Windows bash: node n8n/test_w3.js)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8');
|
||||
|
||||
// ---- fixtures (mirror NocoDB records / AList API shapes) ----
|
||||
// Resources: Id -> alist_path
|
||||
const RES = { 11: '/Class/Forex', 12: '/Class/Python', 13: '/Manual/Ebook' };
|
||||
// Products: Id -> [resourceIds]
|
||||
const PROD_RES = { 101: [11, 12], 102: [13] };
|
||||
// Customers: Id -> {alist_role_id, alist_user_id, status, disabled}
|
||||
const CUST = {
|
||||
1: { alist_role_id: 501, alist_user_id: 901, status: 'active', disabled: false }, // qualified; has expired CP
|
||||
2: { alist_role_id: 502, alist_user_id: 902, status: 'active', disabled: false }, // all expired -> should clear+disable(if on)
|
||||
3: { alist_role_id: 503, alist_user_id: 903, status: 'active', disabled: false }, // no scopes now
|
||||
4: { alist_role_id: null, alist_user_id: 904, status: 'active', disabled: false }, // not provisioned -> skip
|
||||
};
|
||||
// CustomerProducts: {Id, customer:Id, product:Id, expires_at, status}
|
||||
const CPS = {
|
||||
1: [{ cid: 1, product: 101, expires_at: '2026-12-31', status: 'active' }, // live -> /Class/Forex,/Class/Python
|
||||
{ cid: 1, product: 102, expires_at: '2020-01-01', status: 'active' }], // expired -> dropped
|
||||
2: [{ cid: 2, product: 101, expires_at: '2020-01-01', status: 'active' }], // expired -> empty
|
||||
3: [],
|
||||
};
|
||||
// AccessGrants: active manual grant for cust 1 on /Manual/Ebook
|
||||
const GRANTS = {
|
||||
1: [{ resource: 13, status: 'active', expires_at: null }],
|
||||
};
|
||||
// Current AList role scopes
|
||||
const ROLE_SCOPES = {
|
||||
501: ['/Class/Forex', '/Stale/Old'], // has stale -> remove; needs /Class/Python,/Manual/Ebook
|
||||
502: ['/Class/Forex'], // all expired -> clear
|
||||
503: [],
|
||||
};
|
||||
|
||||
// ---- mock httpRequest keyed by URL ----
|
||||
const calls = { roleUpdate: [], userUpdate: [] };
|
||||
function httpRequest({ method, url, headers, body }) {
|
||||
if (url.endsWith('/api/auth/login')) return { data: { token: 'tk-<TEST>' } };
|
||||
const g = (m) => new RegExp(m).exec(url);
|
||||
let m;
|
||||
// per-table list endpoints
|
||||
if (url.includes('/tables/RES/records?limit=10000')) {
|
||||
return { list: Object.keys(RES).map((Id) => ({ Id: Number(Id), alist_path: RES[Id] })) };
|
||||
}
|
||||
if (url.includes('/tables/GRANT/records?limit=10000')) {
|
||||
const list = [];
|
||||
for (const cid of Object.keys(GRANTS)) for (const gg of GRANTS[cid]) list.push({ ...gg, customer: { Id: Number(cid) } });
|
||||
return { list };
|
||||
}
|
||||
if (url.includes('/tables/CUST/records?limit=10000')) {
|
||||
return { list: Object.keys(CUST).map((Id) => ({ Id: Number(Id), username: 'u' + Id, ...CUST[Id] })) };
|
||||
}
|
||||
// customerproducts by customer: /tables/CP/records?where=(customer.Id,eq,N)&limit=1000
|
||||
if ((m = g('/tables/CP/records\\?where=\\(customer.Id,eq,([0-9]+)\\)&limit=1000'))) {
|
||||
const cid = Number(m[1]);
|
||||
return { list: (CPS[cid] || []).map((c) => ({ ...c, customer: { Id: cid } })) };
|
||||
}
|
||||
// product links: /tables/PROD/links/LNK/records/{pid}
|
||||
if ((m = g('/tables/PROD/links/LNK/records/([0-9]+)'))) {
|
||||
const pid = Number(m[1]);
|
||||
return { list: (PROD_RES[pid] || []).map((Id) => ({ Id })) };
|
||||
}
|
||||
// role get
|
||||
if ((m = g('/api/admin/role/get\\?id=([0-9]+)'))) {
|
||||
const rid = Number(m[1]);
|
||||
return { data: { id: rid, name: 'r' + rid, description: '', permission_scopes: (ROLE_SCOPES[rid] || []).map((path) => ({ path, permission: 0 })) } };
|
||||
}
|
||||
if (url.endsWith('/api/admin/role/update')) { calls.roleUpdate.push(body); return { data: null }; }
|
||||
if (url.endsWith('/api/admin/user/update')) { calls.userUpdate.push(body); return { data: null }; }
|
||||
throw new Error('no mock for ' + method + ' ' + url);
|
||||
}
|
||||
|
||||
const $env = {
|
||||
NOCODB_URL: 'http://nc', NOCODB_TOKEN: 't',
|
||||
ALIST_URL: 'http://alist',
|
||||
NOCODB_TABLE_CUSTOMERS: 'CUST', NOCODB_TABLE_CUSTOMERPRODUCTS: 'CP',
|
||||
NOCODB_TABLE_PRODUCTS: 'PROD', NOCODB_TABLE_RESOURCES: 'RES',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'GRANT', NOCODB_LINK_PROD_RESOURCES: 'LNK',
|
||||
ALIST_ADMIN_USERNAME: 'a', ALIST_ADMIN_PASSWORD: 'p',
|
||||
ALIST_AUTO_DISABLE_ON_EXPIRE: process.env.TEST_DISABLE === '1' ? 'true' : 'false',
|
||||
W3_DRY_RUN: 'false',
|
||||
};
|
||||
// w3-compute uses plain (non-arrow) functions -> `this` resolves to globalThis
|
||||
// when called bare. Wrap in an async IIFE so the file's top-level `await` works
|
||||
// (n8n Code nodes also support top-level await). The file ends with `return {json:{...}}`
|
||||
// which becomes the IIFE's resolve value ( {json: payload} ).
|
||||
globalThis.helpers = { httpRequest };
|
||||
|
||||
(async () => {
|
||||
const runner = new Function('$env', '$json', 'return (async () => {\n' + src + '\n})();');
|
||||
const json = await runner($env, {});
|
||||
const out = json.json; // unwrap the {json: {...}} wrapper
|
||||
console.log('SUMMARY:', JSON.stringify(out, null, 2));
|
||||
console.log('SUMMARY:', JSON.stringify(out, null, 2));
|
||||
|
||||
// ---- assertions ----
|
||||
const fail = (msg) => { console.error('FAIL: ' + msg); process.exitCode = 1; };
|
||||
const ok = () => {};
|
||||
const changed = out.changed;
|
||||
|
||||
const c1 = changed.find((c) => c.customerId === 1);
|
||||
if (!c1) fail('cust1 should change'); else {
|
||||
if (!c1.toRemove.includes('/Stale/Old')) fail('cust1: stale /Stale/Old not removed');
|
||||
if (!c1.toAdd.includes('/Class/Python')) fail('cust1: /Class/Python not added');
|
||||
if (!c1.toAdd.includes('/Manual/Ebook')) fail('cust1: manual /Manual/Ebook not added');
|
||||
if (c1.toAdd.includes('/Class/Forex')) fail('cust1: /Class/Forex already present, should not be toAdd');
|
||||
if (c1.applied.includes('role_scopes') === false) ok();
|
||||
}
|
||||
const c2 = changed.find((c) => c.customerId === 2);
|
||||
if (!c2) fail('cust2 should change (all expired -> clear)'); else {
|
||||
if (!c2.toRemove.includes('/Class/Forex')) fail('cust2: expired /Class/Forex not cleared');
|
||||
if (c2.desiredPaths.length !== 0) fail('cust2 desiredPaths should be empty');
|
||||
if (process.env.TEST_DISABLE === '1' && !c2.applied.includes('disable')) fail('cust2 should disable when auto-disable ON');
|
||||
if (process.env.TEST_DISABLE !== '1' && c2.applied.includes('disable')) fail('cust2 must NOT disable when auto-disable OFF');
|
||||
}
|
||||
const c3 = changed.find((c) => c.customerId === 3);
|
||||
if (c3) fail('cust3 (already empty scope) should NOT change');
|
||||
if (changed.find((c) => c.customerId === 4)) fail('cust4 (no role) must be skipped');
|
||||
|
||||
// inspect actual writes
|
||||
const ru = calls.roleUpdate.find((b) => b.id === 501);
|
||||
if (ru) {
|
||||
const paths = (ru.permission_scopes || []).map((s) => s.path);
|
||||
if (!paths.includes('/Class/Forex')) fail('write cust1 lost /Class/Forex');
|
||||
if (!paths.includes('/Manual/Ebook')) fail('write cust1 missing manual grant');
|
||||
if (paths.includes('/Stale/Old')) fail('write cust1 kept stale');
|
||||
} else fail('no role/update call for cust1');
|
||||
if (process.env.TEST_DISABLE === '1' && !calls.userUpdate.find((b) => b.id === 902)) fail('cust2 user not disabled');
|
||||
|
||||
const nFail = Object.keys(calls.roleUpdate).length; // crude; we already assert specifics
|
||||
console.log(process.exitCode ? '\n>>> TEST FAILED' : '\n>>> ALL CHECKS PASSED');
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
// Verify the REFACTORED W3 body produces correct reconciliation after the
|
||||
// calculateEffectiveGrants extraction into shared/. Runs the ACTUAL inlined
|
||||
// code from W3-expiry-sync.json against a mock ctx.
|
||||
// Run: node n8n/test_w3_refactored.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W3-expiry-sync.json'), 'utf8'));
|
||||
const code = workflow.nodes.find(n => n.name === 'Reconcile & Apply (Expiry Sweep)').parameters.jsCode;
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
const RESOURCES = [
|
||||
{ Id: 1, alist_path: '/Class/Python/01' },
|
||||
{ Id: 2, alist_path: '/Class/Health' },
|
||||
{ Id: 3, alist_path: '/Template/Music' },
|
||||
];
|
||||
const PRODUCT_RES = { 2: [{ Id: 2 }], 3: [{ Id: 3 }], 4: [{ Id: 1 }] };
|
||||
const CUSTOMERS = [
|
||||
{ Id: 1, username: 'cust1', alist_role_id: 10, alist_user_id: 100, status: 'active' },
|
||||
{ Id: 2, username: 'cust2', alist_role_id: 11, alist_user_id: 101, status: 'active' },
|
||||
{ Id: 3, username: 'cust3', alist_role_id: null, alist_user_id: null, status: 'active' },
|
||||
];
|
||||
const CPS = {
|
||||
1: [{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }],
|
||||
2: [{ Id: 2, customer: 2, product: 3, status: 'active', expires_at: TODAY }],
|
||||
};
|
||||
const GRANTS = {
|
||||
1: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }],
|
||||
2: [],
|
||||
};
|
||||
const ROLE_SCOPES = {
|
||||
10: [{ path: '/Class/Python/01', permission: 0 }],
|
||||
11: [{ path: '/Template/Music', permission: 0 }],
|
||||
};
|
||||
|
||||
const wroteRole = [], disabledUsers = [];
|
||||
|
||||
const sandbox = {
|
||||
$env: {
|
||||
NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al',
|
||||
NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc',
|
||||
NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_LINK_PROD_RESOURCES: 'lnk',
|
||||
ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p',
|
||||
W3_DRY_RUN: 'false', ALIST_AUTO_DISABLE_ON_EXPIRE: 'true',
|
||||
wroteRole, disabledUsers,
|
||||
},
|
||||
console,
|
||||
};
|
||||
sandbox.helpers = {
|
||||
httpRequest: async (opts) => {
|
||||
const u = opts.url;
|
||||
if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } };
|
||||
if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES };
|
||||
if (u.includes('/tc/records?limit=10000')) return { list: CUSTOMERS };
|
||||
const cpm = u.match(/tcpc\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (cpm) return { list: CPS[Number(cpm[1])] || [] };
|
||||
const gm = u.match(/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (gm) return { list: GRANTS[Number(gm[1])] || [] };
|
||||
const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/);
|
||||
if (pm) return { list: PRODUCT_RES[Number(pm[1])] || [] };
|
||||
const rg = u.match(/role\/get\?id=(\d+)/);
|
||||
if (rg) return { data: { permission_scopes: ROLE_SCOPES[Number(rg[1])] || [] } };
|
||||
if (u.endsWith('/api/admin/role/update')) { wroteRole.push(opts.body); return { code: 200 }; }
|
||||
if (u.endsWith('/api/admin/user/update')) { disabledUsers.push(opts.body); return { code: 200 }; }
|
||||
throw new Error('unexpected URL: ' + u);
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// Wrap exactly like n8n: top-level await with `this` bound to a node context
|
||||
// that exposes `.helpers` and `$env`.
|
||||
const wrapped = `(async function(){\n${code}\n})`;
|
||||
const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w3-body.js' });
|
||||
const result = await fn();
|
||||
const changed = result.json.changed;
|
||||
const errors = result.json.errors;
|
||||
|
||||
let fail = 0;
|
||||
const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } };
|
||||
|
||||
A('scanned 3', result.json.customersScanned === 3, result.json.customersScanned);
|
||||
A('cust3 skipped', !changed.find(c => c.customerId === 3), changed);
|
||||
A('no errors', errors.length === 0, errors);
|
||||
const c1 = changed.find(c => c.customerId === 1);
|
||||
A('cust1 changed', !!c1, changed);
|
||||
if (c1) {
|
||||
A('cust1 add Health', c1.toAdd.includes('/Class/Health'), c1.toAdd);
|
||||
A('cust1 add Music (grant)', c1.toAdd.includes('/Template/Music'), c1.toAdd);
|
||||
A('cust1 remove stale Python', c1.toRemove.includes('/Class/Python/01'), c1.toRemove);
|
||||
}
|
||||
const c2 = changed.find(c => c.customerId === 2);
|
||||
A('cust2 changed', !!c2, changed);
|
||||
if (c2) {
|
||||
A('cust2 remove Music', c2.toRemove.includes('/Template/Music'), c2.toRemove);
|
||||
A('cust2 add empty', c2.toAdd.length === 0, c2.toAdd);
|
||||
A('cust2 disabled', c2.applied.includes('disable'), c2.applied);
|
||||
}
|
||||
A('2 role updates', wroteRole.length === 2, wroteRole.length);
|
||||
A('1 disable', disabledUsers.length === 1, disabledUsers.length);
|
||||
|
||||
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
main().then(() => {}).catch(e => { console.error(e); process.exit(1); });
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// ============================================================================
|
||||
// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3).
|
||||
// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js).
|
||||
//
|
||||
// Single self-contained Code node (plus webhook + id extract). Replaces the
|
||||
// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.
|
||||
//
|
||||
// LIFECYCLE MATRIX (account-level state, 2026-08-23):
|
||||
//
|
||||
// status active (no user yet) -> provision (create role + user), recompute
|
||||
// effective grants, enable user.
|
||||
// status active (has user) -> re-enable + RECOMPUTE effective grants
|
||||
// (never restore stale scopes), apply.
|
||||
//
|
||||
// status pending/suspended/expired/deleted:
|
||||
// - has user/role -> disable AList user + clear role scopes (soft; keep
|
||||
// user + role + CustomerProducts + history).
|
||||
// - no user yet -> no-op (pure NocoDB label; do NOT create AList user).
|
||||
//
|
||||
// All resume transitions (pending/suspended/expired/deleted -> active) recompute
|
||||
// from current CustomerProducts + Product Resources + expiry; never restore old
|
||||
// scopes, and never mutate CustomerProducts automatically.
|
||||
//
|
||||
// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):
|
||||
// We distinguish an ADMIN business change from our OWN write-back echo using
|
||||
// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the
|
||||
// brittle `sync_status === 'synced'` check.
|
||||
//
|
||||
// businessChanged = a BUSINESS field moved (status / username / password /
|
||||
// display_name / email / notes) OR `sync_required` is set.
|
||||
// echo = only our bookkeeping fields moved (alist_user_id,
|
||||
// alist_role_id, sync_status, sync_required=false,
|
||||
// last_sync, last_sync_error).
|
||||
//
|
||||
// We always write back with `sync_required: false` (and clear it), so our own
|
||||
// echo re-enters with businessChanged === false -> skip immediately, before
|
||||
// any AList mutation. An admin flipping status (or setting sync_required) is
|
||||
// the ONLY thing that proceeds past the guard — exactly once.
|
||||
//
|
||||
// Value-change write-back guard: writeBack() re-reads the record and only
|
||||
// PATCHes fields that would actually CHANGE. Writing the same value produces
|
||||
// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.
|
||||
//
|
||||
// Idempotency: final state is converged per status (see above). Duplicate or
|
||||
// echo webhooks re-apply the same end state with zero net change.
|
||||
// ============================================================================
|
||||
// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)
|
||||
// ============================================================================
|
||||
|
||||
const ctx = { $env, helpers: this.helpers };
|
||||
|
||||
// 1. fresh customer record by id (from webhook extract)
|
||||
const customerId = $json.customerId;
|
||||
|
||||
// NocoDB v2 webhook payload carries the pre/post rows. The extract node already
|
||||
// surfaced customerId; here we re-read the record for the authoritative
|
||||
// CURRENT field values (source of truth = NocoDB), and separately inspect the
|
||||
// event's old/new rows to decide business-change vs echo.
|
||||
const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
||||
const status = cust.status || 'active';
|
||||
const username = cust.username;
|
||||
if (!username) throw new Error('Customer missing username');
|
||||
const alistUserId = cust.alist_user_id ?? null;
|
||||
const alistRoleId = cust.alist_role_id ?? null;
|
||||
const alreadyProvisioned = !!(alistUserId && alistRoleId);
|
||||
|
||||
// 2. admin login
|
||||
const login = await alistReq(ctx, 'POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// 3. LOOP GUARD — distinguish admin business change from our own echo.
|
||||
// The extract node passes through the raw rows so we can diff. Fall back to
|
||||
// "always proceed" if the payload rows are missing (better a redundant
|
||||
// idempotent provision than a silently dropped admin change).
|
||||
const oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;
|
||||
const newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;
|
||||
|
||||
// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,
|
||||
// sync_*, last_sync*) are what WE write and must never by themselves count as
|
||||
// a business change.
|
||||
const BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];
|
||||
|
||||
function businessChanged() {
|
||||
// sync_required explicitly set by admin => force reprocess (one-shot override).
|
||||
if (cust.sync_required === true || cust.sync_required === 1) return true;
|
||||
|
||||
// After-INSERT: there is a new row but no previous row. This is a fresh
|
||||
// customer creation -> treat as a business change (provision / deactivate).
|
||||
if (newRow && !oldRow) return true;
|
||||
|
||||
// If we have old+new rows, compare only business fields.
|
||||
if (oldRow && newRow) {
|
||||
for (const f of BUSINESS_FIELDS) {
|
||||
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
||||
}
|
||||
// Any business field present in new but matching old, or bookkeeping-only
|
||||
// delta => echo. Also guard against a field we didn't list changing: if a
|
||||
// business field existed in the row and moved, catch it generically.
|
||||
for (const f of Object.keys(newRow)) {
|
||||
// skip pure bookkeeping fields
|
||||
if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;
|
||||
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both rows missing (unknown shape): fall back to provisioning only when
|
||||
// provision is genuinely incomplete OR status changed from what AList has.
|
||||
// To stay safe against loops, treat "no info" as skip-UNLESS-sync_required,
|
||||
// which admin can set explicitly.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!businessChanged()) {
|
||||
// Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.
|
||||
// Nothing is written back here, so no further webhook is generated.
|
||||
return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };
|
||||
}
|
||||
|
||||
// 4. STATUS BRANCH ---------------------------------------------------------
|
||||
|
||||
// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only
|
||||
// fields whose value actually differs. If nothing differs -> no PATCH -> no
|
||||
// webhook -> loop is physically broken at the source.
|
||||
const writeBack = async (fields) => {
|
||||
const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
||||
const changes = {};
|
||||
for (const k of Object.keys(fields)) {
|
||||
const want = fields[k];
|
||||
const have = live[k];
|
||||
if (want !== have) changes[k] = want;
|
||||
}
|
||||
if (Object.keys(changes).length === 0) return; // no-op: nothing changed
|
||||
await ctx.helpers.httpRequest({
|
||||
method: 'PATCH',
|
||||
url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,
|
||||
headers: { 'xc-token': $env.NOCODB_TOKEN },
|
||||
body: [Object.assign({ Id: customerId }, changes)],
|
||||
json: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (status !== 'active') {
|
||||
// pending / suspended / expired / deleted / unknown -> deactivate.
|
||||
// - has user/role: disable AList user + clear role scopes (soft).
|
||||
// - no user yet: no-op (pure label; do NOT create AList user).
|
||||
if (!alistUserId && !alistRoleId) {
|
||||
return { json: { customerId, username, status, lifecycle: 'noop_not_provisioned', actions: [] } };
|
||||
}
|
||||
const actions = [];
|
||||
if (alistUserId) {
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);
|
||||
actions.push('disable_user');
|
||||
}
|
||||
if (alistRoleId) {
|
||||
const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token);
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
id: alistRoleId,
|
||||
name: role?.data?.name,
|
||||
description: role?.data?.description,
|
||||
permission_scopes: [],
|
||||
}, token);
|
||||
actions.push('clear_scopes');
|
||||
}
|
||||
// value-change guarded write-back; echo re-entry sees businessChanged()===false.
|
||||
await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });
|
||||
return { json: { customerId, username, status, lifecycle: 'deactivate', actions } };
|
||||
}
|
||||
|
||||
// status === 'active' -------------------------------------------------------
|
||||
|
||||
const actions = [];
|
||||
let finalUserId = alistUserId;
|
||||
let finalRoleId = alistRoleId;
|
||||
|
||||
// 4a. ensure role exists
|
||||
if (!finalRoleId) {
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/create', {
|
||||
name: 'customer-' + username,
|
||||
description: 'Auto role for ' + username,
|
||||
default: false,
|
||||
permission_scopes: [],
|
||||
}, token);
|
||||
const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token);
|
||||
const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username);
|
||||
if (!r) throw new Error('Created role not found: customer-' + username);
|
||||
finalRoleId = r.id;
|
||||
actions.push('create_role');
|
||||
}
|
||||
|
||||
// 4b. ensure user exists / update; always re-enable on active
|
||||
if (!finalUserId) {
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/create', {
|
||||
username,
|
||||
password: cust.password,
|
||||
base_path: '/',
|
||||
role: finalRoleId ? [finalRoleId] : [],
|
||||
permission: 16384,
|
||||
}, token);
|
||||
const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token);
|
||||
const u = (users?.data?.content || []).find(x => x.username === username);
|
||||
if (!u) throw new Error('Created user not found: ' + username);
|
||||
finalUserId = u.id;
|
||||
actions.push('create_user');
|
||||
} else {
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/update', {
|
||||
id: finalUserId,
|
||||
username,
|
||||
password: cust.password,
|
||||
base_path: '/',
|
||||
role: finalRoleId ? [finalRoleId] : [],
|
||||
permission: 16384,
|
||||
disabled: false,
|
||||
}, token);
|
||||
actions.push('update_user');
|
||||
}
|
||||
|
||||
// 4c. RECOMPUTE desired scopes (never restore stale) and write them
|
||||
const g = await calculateEffectiveGrants(ctx, customerId);
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
id: finalRoleId,
|
||||
name: 'customer-' + username,
|
||||
description: 'Auto role for ' + username,
|
||||
permission_scopes: g.newScopes,
|
||||
}, token);
|
||||
actions.push('apply_grants');
|
||||
|
||||
// 5. write-back ids + sync_status + clear sync_required (value-change guarded).
|
||||
// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.
|
||||
await writeBack({
|
||||
alist_user_id: alistUserId || finalUserId,
|
||||
alist_role_id: alistRoleId || finalRoleId,
|
||||
sync_status: 'synced',
|
||||
sync_required: false,
|
||||
last_sync_error: null,
|
||||
});
|
||||
|
||||
return { json: {
|
||||
customerId, username, status, lifecycle: 'active_provision',
|
||||
alistUserId: finalUserId, alistRoleId: finalRoleId,
|
||||
desiredPaths: g.desiredPaths, actions,
|
||||
} };
|
||||
@@ -0,0 +1,77 @@
|
||||
// ============================================================================
|
||||
// W2 runtime body — enforced CustomerProduct -> AList role scopes sync.
|
||||
// Rebuilt by `node n8n/gen_w2.js` (single source of truth = shared/effective-grants.js).
|
||||
// ============================================================================
|
||||
// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)
|
||||
// ============================================================================
|
||||
|
||||
// n8n Code node context: ctx = { $env, helpers } — but helpers is `this.helpers`
|
||||
// inside the node. Build ctx to hand to the shared library.
|
||||
const ctx = {
|
||||
$env,
|
||||
helpers: this.helpers,
|
||||
};
|
||||
|
||||
const custProdId = $json.customerProductId;
|
||||
|
||||
// 1. re-fetch this CP record to find its customer link (may be null mid-draft)
|
||||
const CP = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERPRODUCTS}/records/${custProdId}`);
|
||||
const customerId = linkId(CP.customer);
|
||||
if (!customerId) {
|
||||
// Customer link not yet filled (record being drafted). Skip gracefully; the
|
||||
// webhook re-fires when customer is set later.
|
||||
return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };
|
||||
}
|
||||
|
||||
// 2. compute what the customer SHOULD have right now (shared algorithm)
|
||||
const grants = await calculateEffectiveGrants(ctx, customerId);
|
||||
|
||||
// 3. fetch customer admin facts (role id, alist user id)
|
||||
const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
||||
const roleId = cust.alist_role_id;
|
||||
const userId = cust.alist_user_id;
|
||||
|
||||
if (!roleId) {
|
||||
return { json: { skipWriteBack: true, customerId, reason: 'customer not provisioned (no role id)' } };
|
||||
}
|
||||
|
||||
// 4. admin login
|
||||
const login = await alistReq(ctx, 'POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// 5. current scopes vs desired scopes -> diff
|
||||
const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${roleId}`, null, token);
|
||||
const currentScopes = role?.data?.permission_scopes ?? [];
|
||||
const currentPaths = currentScopes.map(s => s.path);
|
||||
const toAdd = grants.desiredPaths.filter(p => !currentPaths.includes(p));
|
||||
const toRemove = currentPaths.filter(p => !grants.desiredPaths.includes(p));
|
||||
const changed = toAdd.length > 0 || toRemove.length > 0;
|
||||
|
||||
// 6. apply if changed
|
||||
if (changed) {
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
id: roleId,
|
||||
name: role?.data?.name,
|
||||
description: role?.data?.description,
|
||||
permission_scopes: grants.newScopes,
|
||||
}, token);
|
||||
}
|
||||
|
||||
return {
|
||||
json: {
|
||||
customerId,
|
||||
customerProductId: custProdId,
|
||||
roleId,
|
||||
roleName: role?.data?.name,
|
||||
effective: grants.desired,
|
||||
desiredPaths: grants.desiredPaths,
|
||||
currentScopes,
|
||||
diff: { toAdd, toRemove },
|
||||
newScopes: grants.newScopes,
|
||||
skipWriteBack: changed ? false : true,
|
||||
},
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
// 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) {
|
||||
// Gracefully skip when customer link not yet filled (CP record is being drafted).
|
||||
// The webhook will re-fire when customer is set later.
|
||||
return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };
|
||||
}
|
||||
|
||||
// 2. load all active (and not-yet-expired) CPs for this customer
|
||||
const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
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;
|
||||
if (r.expires_at && String(r.expires_at) <= today) return false; // expired (incl. today)
|
||||
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 })),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// ============================================================================
|
||||
// W3 runtime body — 5-min expiry sweep / full reconciliation.
|
||||
// Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js).
|
||||
// dryRun: W3_DRY_RUN=true -> only print the plan (no writes).
|
||||
// autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a
|
||||
// previously-scoped customer's desired set becomes empty. Default off.
|
||||
// ============================================================================
|
||||
// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)
|
||||
// ============================================================================
|
||||
|
||||
const ctx = { $env, helpers: this.helpers };
|
||||
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
|
||||
const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';
|
||||
const T_CUST = $env.NOCODB_TABLE_CUSTOMERS;
|
||||
|
||||
// admin login once for the whole sweep
|
||||
const login = await alistReq(ctx, 'POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
const customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];
|
||||
const changed = [], errors = [];
|
||||
|
||||
for (const cust of customers) {
|
||||
try {
|
||||
if (!cust.alist_role_id) continue; // not provisioned yet
|
||||
const cid = cust.Id;
|
||||
|
||||
// desired scopes via shared algorithm
|
||||
const g = await calculateEffectiveGrants(ctx, cid);
|
||||
const desiredPaths = g.desiredPaths;
|
||||
|
||||
// current scopes from AList
|
||||
const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);
|
||||
const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);
|
||||
const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));
|
||||
const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));
|
||||
const scopesChanged = toAdd.length > 0 || toRemove.length > 0;
|
||||
const nowEmpty = desiredPaths.length === 0;
|
||||
|
||||
// only disable when a customer that HAD scopes now has none (guard
|
||||
// currentPaths>0 avoids killing a fresh customer with an empty role).
|
||||
const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;
|
||||
|
||||
const applied = [];
|
||||
if (scopesChanged) {
|
||||
if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
id: cust.alist_role_id,
|
||||
name: role?.data?.name,
|
||||
description: role?.data?.description,
|
||||
permission_scopes: g.newScopes,
|
||||
}, token);
|
||||
applied.push('role_scopes');
|
||||
}
|
||||
if (shouldDisable) {
|
||||
if (!DRY_RUN && cust.alist_user_id)
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);
|
||||
applied.push('disable');
|
||||
}
|
||||
if (applied.length) {
|
||||
changed.push({
|
||||
customerId: cid, username: cust.username, roleId: cust.alist_role_id,
|
||||
toAdd, toRemove, desiredPaths, applied,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ customerId: cust.Id, username: cust.username, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { json: {
|
||||
dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),
|
||||
customersScanned: customers.length, customersChanged: changed.length,
|
||||
changed, errors,
|
||||
} };
|
||||
@@ -1,124 +0,0 @@
|
||||
// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write).
|
||||
// Runs every 5 min. idempotent. get current -> compare -> apply diff.
|
||||
// Reads config via $env (works in n8n Code node).
|
||||
// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes).
|
||||
// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty.
|
||||
// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status
|
||||
// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop).
|
||||
|
||||
const NC = $env.NOCODB_URL;
|
||||
const NC_TOKEN = $env.NOCODB_TOKEN;
|
||||
const ALIST = $env.ALIST_URL;
|
||||
const T_CUST = $env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const T_PROD = $env.NOCODB_TABLE_PRODUCTS;
|
||||
const T_RES = $env.NOCODB_TABLE_RESOURCES;
|
||||
const T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS;
|
||||
const LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
|
||||
|
||||
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
|
||||
const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';
|
||||
const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// login once
|
||||
const login = await alistReq('POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// index all Resources by Id -> alist_path
|
||||
const allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];
|
||||
const resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; });
|
||||
|
||||
// all access grants (materialized + manual), grouped by customer
|
||||
const grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || [];
|
||||
const gByCust = {};
|
||||
for (const g of grants) {
|
||||
const cid = linkId(g.customer); if (!cid) continue;
|
||||
(gByCust[cid] = gByCust[cid] || []).push(g);
|
||||
}
|
||||
|
||||
const customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];
|
||||
const changed = [], errors = [];
|
||||
|
||||
for (const cust of customers) {
|
||||
try {
|
||||
if (!cust.alist_role_id) continue; // not provisioned
|
||||
const cid = cust.Id;
|
||||
// desired = CP-derived paths (active, not expired) union effective grants
|
||||
const desired = {}; // path -> true
|
||||
const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || [];
|
||||
for (const c of cps) {
|
||||
if (c.status && c.status !== 'active') continue;
|
||||
if (c.expires_at && c.expires_at <= today) continue; // expired (incl. today)
|
||||
const pid = linkId(c.product); if (!pid) continue;
|
||||
const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
|
||||
for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; }
|
||||
}
|
||||
for (const g of (gByCust[cid] || [])) {
|
||||
if (g.status && g.status !== 'active') continue;
|
||||
if (g.expires_at && g.expires_at <= today) continue;
|
||||
const p = resPath[linkId(g.resource)]; if (p) desired[p] = true;
|
||||
}
|
||||
const desiredPaths = Object.keys(desired).sort();
|
||||
|
||||
const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);
|
||||
const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);
|
||||
const toRemove = currentPaths.filter(p => !desired[p]);
|
||||
const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));
|
||||
const scopesChanged = toAdd.length > 0 || toRemove.length > 0;
|
||||
const nowEmpty = desiredPaths.length === 0;
|
||||
// WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable.
|
||||
// Guard currentPaths>0: never disable a fresh customer with an empty role (avoids
|
||||
// killing a just-provisioned user before they buy anything; W2 won't re-enable them).
|
||||
const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;
|
||||
|
||||
let applied = [];
|
||||
if (scopesChanged) {
|
||||
const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 }));
|
||||
if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update',
|
||||
{ id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description,
|
||||
permission_scopes: newScopes }, token);
|
||||
applied.push('role_scopes');
|
||||
}
|
||||
if (shouldDisable) {
|
||||
if (!DRY_RUN && cust.alist_user_id)
|
||||
await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);
|
||||
applied.push('disable');
|
||||
}
|
||||
if (applied.length) {
|
||||
changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id,
|
||||
toAdd, toRemove, desiredPaths, applied });
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ customerId: cust.Id, username: cust.username, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { json: {
|
||||
dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),
|
||||
customersScanned: customers.length, customersChanged: changed.length,
|
||||
changed, errors,
|
||||
} };
|
||||
Reference in New Issue
Block a user