From a2c431e35935287b924aecb3ee2b6452c40a5b45 Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Wed, 12 Aug 2026 08:27:13 +0530 Subject: [PATCH] implement policy to access resources Signed-off-by: Jeeva Kandasamy --- .gitignore | 1 + cmd/helper/server_defaults.go | 2 + docs/access-control.md | 770 ++++++++++++++++++ pkg/api/backup/api.go | 16 +- pkg/api/backup/api_impl.go | 65 +- pkg/api/entities/api.go | 5 + pkg/api/policy/action_auth.go | 131 +++ pkg/api/policy/api.go | 315 +++++++ pkg/api/policy/body_auth.go | 244 ++++++ pkg/api/policy/body_auth_test.go | 247 ++++++ pkg/api/policy/cache.go | 229 ++++++ pkg/api/policy/defaults.go | 67 ++ pkg/api/policy/engine.go | 609 ++++++++++++++ pkg/api/policy/engine_test.go | 312 +++++++ pkg/api/policy/list_filter.go | 168 ++++ pkg/api/policy/list_filter_test.go | 36 + pkg/api/policy/list_integration_test.go | 303 +++++++ pkg/api/policy/mapper.go | 256 ++++++ pkg/api/policy/mapper_test.go | 21 + pkg/api/policy/match.go | 198 +++++ pkg/api/policy/match_test.go | 89 ++ pkg/api/policy/metric_auth.go | 175 ++++ pkg/api/policy/metric_auth_test.go | 30 + pkg/api/policy/query_filters.go | 268 ++++++ pkg/api/policy/query_filters_test.go | 156 ++++ pkg/api/policy/quickid_auth.go | 88 ++ pkg/api/policy/quickid_auth_test.go | 90 ++ pkg/api/policy/resolve.go | 126 +++ pkg/api/policy/sleeping_queue_auth.go | 36 + pkg/api/policy/sleeping_queue_auth_test.go | 56 ++ pkg/api/policy/token_scope_test.go | 145 ++++ pkg/api/service_token/api.go | 52 +- pkg/api/user/api.go | 96 ++- pkg/backup/backup_map.go | 1 + pkg/http_router/handler.go | 41 +- pkg/http_router/middleware/auth.go | 217 ++++- pkg/http_router/middleware/auth_paths_test.go | 51 ++ pkg/http_router/routes/auth/auth.go | 7 + pkg/http_router/routes/auth/oauth.go | 7 +- pkg/http_router/routes/policy.go | 62 ++ pkg/http_router/routes/routes.go | 2 + pkg/http_router/routes/service_token.go | 57 +- pkg/http_router/routes/user.go | 121 +++ pkg/service/http_listener/https/ssl_test.go | 2 +- pkg/types/entities.go | 1 + pkg/types/policy/types.go | 109 +++ pkg/types/service_token/types.go | 10 +- pkg/types/user/types.go | 14 + pkg/types/web_handler/types.go | 5 +- pkg/upgrade/v2_2_0__1.go | 29 + pkg/upgrade/versions.go | 1 + pkg/utils/filter_sort/utils_filter.go | 84 ++ pkg/utils/http_handler/handler_http_utils.go | 12 +- .../http_handler/handler_http_utils_test.go | 45 + .../http_handler/handler_storage_utils.go | 37 + pkg/utils/http_handler/list_rbac.go | 41 + plugin/database/storage/mongodb/client.go | 146 +++- plugin/database/storage/types/storage.go | 13 +- 58 files changed, 6400 insertions(+), 117 deletions(-) create mode 100644 docs/access-control.md create mode 100644 pkg/api/policy/action_auth.go create mode 100644 pkg/api/policy/api.go create mode 100644 pkg/api/policy/body_auth.go create mode 100644 pkg/api/policy/body_auth_test.go create mode 100644 pkg/api/policy/cache.go create mode 100644 pkg/api/policy/defaults.go create mode 100644 pkg/api/policy/engine.go create mode 100644 pkg/api/policy/engine_test.go create mode 100644 pkg/api/policy/list_filter.go create mode 100644 pkg/api/policy/list_filter_test.go create mode 100644 pkg/api/policy/list_integration_test.go create mode 100644 pkg/api/policy/mapper.go create mode 100644 pkg/api/policy/mapper_test.go create mode 100644 pkg/api/policy/match.go create mode 100644 pkg/api/policy/match_test.go create mode 100644 pkg/api/policy/metric_auth.go create mode 100644 pkg/api/policy/metric_auth_test.go create mode 100644 pkg/api/policy/query_filters.go create mode 100644 pkg/api/policy/query_filters_test.go create mode 100644 pkg/api/policy/quickid_auth.go create mode 100644 pkg/api/policy/quickid_auth_test.go create mode 100644 pkg/api/policy/resolve.go create mode 100644 pkg/api/policy/sleeping_queue_auth.go create mode 100644 pkg/api/policy/sleeping_queue_auth_test.go create mode 100644 pkg/api/policy/token_scope_test.go create mode 100644 pkg/http_router/middleware/auth_paths_test.go create mode 100644 pkg/http_router/routes/policy.go create mode 100644 pkg/http_router/routes/user.go create mode 100644 pkg/types/policy/types.go create mode 100644 pkg/upgrade/v2_2_0__1.go create mode 100644 pkg/utils/http_handler/handler_http_utils_test.go create mode 100644 pkg/utils/http_handler/list_rbac.go diff --git a/.gitignore b/.gitignore index 95f0043..f123649 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !/resources/control-scripts !/resources/sample*.yaml /scripts/push_bin.sh +/scripts/.web_console_dir binaries/* builds/* .vscode/* diff --git a/cmd/helper/server_defaults.go b/cmd/helper/server_defaults.go index 24b4d97..978e560 100644 --- a/cmd/helper/server_defaults.go +++ b/cmd/helper/server_defaults.go @@ -116,6 +116,8 @@ func (s *Server) setupInitialUser() { Password: hashedPassword, FullName: "Admin User", Email: "admin@example.com", + Policies: []string{"admin"}, + Disabled: false, } err = s.api.User().Save(adminUser) if err != nil { diff --git a/docs/access-control.md b/docs/access-control.md new file mode 100644 index 0000000..e764c09 --- /dev/null +++ b/docs/access-control.md @@ -0,0 +1,770 @@ +# Access control (policies) + +This document describes MyController’s **policy-based access control**: how identities, policies, resources, and service tokens work, and how to configure them with examples. + +--- + +## 1. Overview + +MyController authorizes HTTP API calls with: + +1. **Authentication** – valid JWT (login or service token). +2. **User state** – user must exist and must not be **disabled**. +3. **Service token** (if used) – must exist, belong to the user, and not be expired. +4. **Authorization** – at least one attached **policy** must **Allow** the requested **action** on the requested **resource**. + +There is no multi-tenant isolation in this model. Policies define _what_ a principal may do on _which_ named resources. + +### Naming + +This feature is **policy-based access control**: reusable **policies** are attached to **users** (and optionally narrowed on service tokens). It is not classical role-based access control (User → Role → permissions). + +| Term | Meaning | +| ----------------- | -------------------------------------------------- | +| **Policy** | Reusable permission document (`id` + `statements`) | +| **User.policies** | List of policy ids attached to the user | +| **Statement** | An allow rule: `actions` + `resources` | + +### Design summary + +| Concept | Role | +| ------------------- | -------------------------------------------------------------------------------- | +| **User** | Identity; holds password, `disabled`, and a list of **policy IDs** | +| **Policy** | Named document: list of **statements** (effect, actions, resources) | +| **Service token** | Always tied to a user; optional **extra limits** that can only **reduce** access | +| **Resource string** | `kind` or `kind:name` (name may use hierarchical wildcards) | +| **Action** | Verb such as `get`, `list`, `update`, `delete`, … | + +Effective access for a service token: + +```text +effective = permissions(user policies) ∩ token restrictions (if any) +``` + +If the token has no restrictions, it has the same rights as the user. + +--- + +## 2. Concepts + +### 2.1 Users + +Relevant fields: + +| Field | Description | +| ---------- | -------------------------------------------------------------------- | +| `id` | Storage identifier | +| `username` | Login name | +| `disabled` | If `true`, login and all API calls with that user’s JWT fail (`401`) | +| `policies` | List of policy IDs attached to this user | + +Manage users: + +- API: `/api/user` +- Web UI: **Settings → Users** + +### 2.2 Policies + +A policy is a reusable permission document. + +```yaml +id: plant-room-viewer +description: Read-only access to the plant room device tree +system: false +statements: + - effect: Allow + actions: + - get + - list + resources: + - gateway:plant-room + - node:plant-room.* + - source:plant-room.* + - field:plant-room.* + - metric:plant-room.* + - dashboard + - quickid + - status +``` + +| Field | Description | +| ------------ | ----------------------------------------------------------------------- | +| `id` | Stable name used when attaching the policy to users | +| `system` | Built-in policies (`admin`, `readwrite`, `readonly`); cannot be deleted | +| `statements` | List of allow rules (see below) | + +Manage policies: + +- API: `/api/policy` +- Web UI: **Settings → Policies** + +### 2.3 Statements + +Each statement has: + +| Field | Description | +| ----------- | ---------------------------------------- | +| `effect` | `Allow` or `Deny` | +| `actions` | List of verbs, or `*` for all | +| `resources` | List of resource strings, or `*` for all | + +Evaluation (across **all** statements on **all** attached policies): + +1. If any matching statement is **`Deny`** → **denied** (Deny wins). +2. Else if any matching statement is **`Allow`** → **allowed**. +3. Else → **denied** (default deny). + +Empty `effect` is treated as `Allow`. + +**Device-tree cascade** (gateway → node → source → field/metric): + +Both **Allow** and **Deny** cascade **down** the path. **Deny always wins.** + +| Statement | Effect | +| ---------------------------------- | --------------------------------------------------- | +| Allow `gateway:X` | Also allows node/source/field/metric under `X` | +| Allow `node:X.Y` or `node:X.*` | Also allows source/field/metric under that path | +| Allow `source:X.Y.Z` | Also allows field/metric under that source | +| Deny `node:X.Y` | Blocks that node **and** its sources/fields/metrics | +| Deny `node:X.Y` + Allow `node:X.*` | Sibling nodes (and their fields) stay allowed | + +So a compact policy is enough: + +```yaml +statements: + - effect: Allow + actions: [get, list] + resources: + - gateway:mysensor + - node:mysensor.* + - effect: Deny + actions: [get, list] + resources: + - node:mysensor.1 +``` + +You do **not** need separate `source:` / `field:` lines unless you want a narrower +scope (e.g. only one source). Kind-wide Deny (`node`, `node:*`, `*`) still blocks +the whole kind (and descendants). + +### 2.4 Service tokens + +Service tokens always have a `userId`. They act **as that user**, with optional tightening: + +| Field | Description | +| --------------------------- | ------------------------------------------------------------- | +| `userId` | Owning user (immutable after create) | +| `neverExpire` / `expiresOn` | Lifetime of the token | +| `actions` | Optional: only these actions (subset of the user’s) | +| `resources` | Optional: only these resource patterns (subset of the user’s) | + +Empty `actions` and `resources` mean “no extra limit” (same as the user). + +Evaluation: + +```text +1. User policies must allow (action, resource) +2. If token has restrictions, they must also allow (action, resource) +``` + +A token **cannot** grant more than the user has. + +--- + +## 3. Actions + +| Action | Typical HTTP use | +| --------- | -------------------------------------------------------------- | +| `get` | `GET /api/{kind}/{id}`, metrics query, quickid, status details | +| `list` | `GET /api/{kind}` (collection) | +| `create` | Create-style endpoints (when mapped) | +| `update` | `POST` create-or-update body | +| `delete` | `DELETE` | +| `enable` | `.../enable` | +| `disable` | `.../disable` | +| `reload` | `.../reload` | +| `action` | `/api/action`, node/gateway actions | +| `*` | All actions | + +--- + +## 4. Resource kinds (complete list) + +Resource strings look like: + +```text +kind +kind:name +kind:name-with.dots.and.* +* +``` + +All kinds recognized by the authorization engine are listed below. +**RO/RW** = included in built-in `readonly` / `readwrite`. +**Admin** = only via `admin` (`*`) or an explicit custom policy. + +### 4.0 Full inventory + +| Kind | Primary APIs | Name for fine-grained rules | In `readonly` / `readwrite` | +| ------------------ | ----------------------------- | ------------------------------------------------------------------- | ------------------------------------------ | +| `gateway` | `/api/gateway` | `gatewayId` | Yes | +| `node` | `/api/node` | `gatewayId.nodeId` | Yes | +| `source` | `/api/source` | `gatewayId.nodeId.sourceId` | Yes | +| `field` | `/api/field` | `gatewayId.nodeId.sourceId.fieldId` | Yes | +| `task` | `/api/task` | task `id` | Yes | +| `schedule` | `/api/schedule` | schedule `id` | Yes | +| `handler` | `/api/handler` | handler `id` | Yes | +| `dashboard` | `/api/dashboard` | dashboard `id` (often UUID) | Yes | +| `firmware` | `/api/firmware` | firmware `id` | Yes | +| `forwardpayload` | `/api/forwardpayload` | id | Yes | +| `datarepository` | `/api/datarepository` | id | Yes | +| `virtualdevice` | `/api/virtualdevice` | id | Yes | +| `virtualassistant` | `/api/virtualassistant` | id | Yes | +| `servicetoken` | `/api/servicetoken` | entity id | Yes | +| `metric` | `/api/metric` | same hierarchy as **field** path | Yes (kind-level in built-ins; see metrics) | +| `action` | `/api/action` | optional target name | Yes | +| `status` | `/api/server/status` | (kind only) | Yes | +| `quickid` | `/api/quickid` | API entry; each `?id=` is checked as the target kind (field/node/…) | Yes | +| `user` | `/api/user` | user id when applicable | **No** (admin / custom) | +| `policy` | `/api/policy` | policy id when applicable | **No** (admin / custom) | +| `settings` | `/api/settings` | (kind only) | **No** (admin / custom) | +| `backup` | `/api/backup`, `/api/restore` | (kind only) | **No** (admin / custom) | +| `*` | all of the above | everything | `admin` only (as `*`) | + +Related endpoints that are **not** separate policy kinds: + +| Endpoint | Behavior | +| --------------------------------- | ----------------------------------------------------------------- | +| `GET /api/status` | Public minimal status; no policy required | +| `GET /api/version` | Not gated as a policy kind in the same way as server status | +| `GET/POST /api/user/login`, OAuth | Public auth entry | +| `GET/POST /api/user/profile` | Own profile; allowed for the logged-in user without `user` rights | +| `/api/gateway-sleeping-queue` | Treated as **gateway** access | +| `/api/firmware/upload/...` | Treated as **firmware** update | + +If a path maps to a segment that is not in the table, the engine still uses that segment as `kind` (normalized for multi-word APIs such as `forwardpayload`). Prefer the kinds above in policies. + +### 4.1 Device tree (hierarchical names) + +Device entities use **protocol / configuration ids**, not only storage UUIDs, for policy names: + +| Kind | Name format | Example | +| --------- | ------------------------------------------- | ------------------------------------------------ | +| `gateway` | `{gatewayId}` | `gateway:plant-room` | +| `node` | `{gatewayId}.{nodeId}` | `node:plant-room.sensor-01` | +| `source` | `{gatewayId}.{nodeId}.{sourceId}` | `source:plant-room.sensor-01.climate` | +| `field` | `{gatewayId}.{nodeId}.{sourceId}.{fieldId}` | `field:plant-room.sensor-01.climate.temperature` | + +Storage may still use a UUID as primary key for node/source/field. For **get by UUID**, the server loads the entity and checks the **business name** above. + +### 4.2 Other entities (usually by `id`) + +| Kind | Name | Notes | +| ------------------ | ----------------- | ---------------------------------- | +| `task` | task `id` | User-chosen or generated id | +| `schedule` | schedule `id` | | +| `handler` | handler `id` | | +| `dashboard` | dashboard `id` | Often a **UUID** created by the UI | +| `firmware` | firmware `id` | | +| `forwardpayload` | id | API path `/api/forwardpayload` | +| `datarepository` | id | API path `/api/datarepository` | +| `virtualdevice` | id | | +| `virtualassistant` | id | | +| `servicetoken` | entity id | | +| `user` | user management | Create/list/update/delete users | +| `policy` | policy management | Create/list/update/delete policies | +| `settings` | system settings | | +| `backup` | backup / restore | | + +### 4.3 API capabilities (not devices) + +| Kind | API | Purpose | +| --------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `metric` | `/api/metric` | Time-series; hierarchical name like **field** path | +| `quickid` | `/api/quickid` | Resolve quick IDs for widgets; **each `id` is authorized as that resource** (e.g. `field:gw.n.s.f`), so Deny on a node also blocks that field via quickid | +| `status` | `/api/server/status` | Detailed server status (authenticated) | +| `action` | `/api/action` | Generic resource actions; **each target is authorized as its own resource**, so `action` alone controls nothing you cannot already write | +| `*` | everything | Full access | + +Note: `GET /api/status` (minimal public status) does not require these policies. + +`settings` is split per document, so a read-only console grant does not expose credentials: + +| Resource | API | Notes | +| ----------------------------------- | --------------------------------------- | ----------------------------------------- | +| `settings:system_settings` | `GET /api/settings/system` | What the console needs; in `readonly`/`readwrite` | +| `settings:system_backup_locations` | `GET /api/settings/backuplocations` | May hold storage credentials | +| `settings:system_dynamic_secrets` | `GET /api/settings/system/jwtsecret/reset` | Resets the JWT secret: signs out everyone | +| `settings` | `POST /api/settings` | Write; the body names the document | + +### 4.4 Wildcards + +| Pattern | Matches | +| ------------------------------------------------ | ----------------------------------------- | +| `*` | All kinds and names | +| `field` or `field:*` | All fields | +| `field:plant-room.*` | All fields under gateway `plant-room` | +| `field:plant-room.sensor-01.*` | All fields under that node | +| `field:plant-room.sensor-01.climate.*` | All fields under that source | +| `field:plant-room.sensor-01.climate.temperature` | Exactly one field | +| `node:plant-room.*` | All nodes under gateway `plant-room` | +| `metric:plant-room.*` | Metrics for all fields under that gateway | + +Trailing `.*` means “this segment and all deeper segments” for hierarchical names. + +--- + +## 5. Built-in policies + +Created automatically on startup / upgrade: + +| Policy ID | Access | +| ----------- | ---------------------------------------------------------------------------------------------------------------------- | +| `admin` | `actions: [*]`, `resources: [*]` | +| `readwrite` | Read/write on operational kinds (devices, tasks, dashboards, metric, …). **No** `user`, `policy`, `settings`, `backup` | +| `readonly` | `get` / `list` on the same operational kinds | + +Existing users with an empty `policies` list receive **`admin`** during upgrade so installs are not locked out. + +Default user on fresh install: `admin` / `admin` with policy `admin`. + +--- + +## 6. How enforcement works + +### 6.1 Request path + +```text +HTTP request + → JWT valid? + → user active (not disabled)? + → service token valid (if present)? + → map path + method → action + resource + → (optional) resolve UUID → business name + → Allowed(user policies ∩ token limits)? ← layer 1: may you reach this endpoint? + → authorize each target named in the body? ← layer 2: may you write *this* object? + → handler / storage +``` + +Most checks run in HTTP middleware. List queries also **inject storage filters** so only allowed rows are returned from the database (not a full load then filter in memory). + +### 6.1a Two layers, and why + +A resource string without a name (`gateway`, not `gateway:gw1`) means **the collection**, and a +collection check is deliberately permissive: a grant on `gateway:gw1` lets you *reach* +`GET /api/gateway` and `POST /api/gateway`, because the row filter or the object check decides +what you may actually touch. + +That matters because several endpoints name their targets in the **body**, not the path: + +| Request | Body | Object level check | +| ----------------------------------------- | ------------------- | -------------------------- | +| `POST /api/gateway` | `{"id":"gw1",...}` | `update` on `gateway:gw1` | +| `POST /api/gateway/enable` | `["gw1","gw2"]` | `enable` on each gateway | +| `DELETE /api/gateway` | `["gw1"]` | `delete` on `gateway:gw1` | +| `POST /api/field` | `{"gatewayId":...}` | `update` on `field:` | +| `POST /api/action` | `[{"resource":…}]` | `action` on each quick id | +| `GET /api/action/node?id=a&id=b` | – | `action` on **every** id | +| `GET /api/quickid?id=…` | – | `get` on each quick id | +| `GET`/`POST /api/metric` | quick id / tags | `get` on the target field | + +A payload that names **no** target — creating an object whose id the server generates — requires a +**kind-wide** grant (`*`, `gateway`, or `gateway:*`). A grant on one named object is never enough to +create new ones, which is what keeps `user:` from being a path to `admin`. + +### 6.1b Service tokens are personal + +`/api/servicetoken` is always scoped to the caller, whatever the policies say. Tokens act as their +owner, so no principal can read, widen (drop the `actions`/`resources` limits, set `neverExpire`) or +delete another principal's tokens. To revoke someone else's access, disable the user. + +### 6.2 List queries + +Example client request: + +```http +GET /api/gateway?filter=[{"k":"id","o":"in","v":["plant-room","workshop"]}] +``` + +Combined with policy `gateway:plant-room` only: + +```text +client filter AND policy scope +→ only plant-room (intersection) +``` + +Multiple policy resource names become an **OR** of groups at query level, then **AND**ed with the client filter. + +### 6.3 Get by UUID + +For `GET /api/node/{uuid}`: + +1. Load node by UUID. +2. Build name `gatewayId.nodeId`. +3. Check `get` on `node:gatewayId.nodeId`. + +Policies should use business names (or wildcards), not node UUIDs, for device tree resources. + +### 6.4 Metrics + +Metrics are **not** covered by `field:…` alone. + +| Resource | Controls | +| ---------- | ------------------------------------------- | +| `field:…` | List/get field configuration and values API | +| `metric:…` | `/api/metric` for that hierarchical path | + +The UI often POSTs metrics with `tags.id = `. The server resolves that UUID to the field path and checks `metric:`. + +Examples: + +```yaml +# All metrics under one gateway +- metric:plant-room.* + +# One node +- metric:plant-room.sensor-01.* + +# One field +- metric:plant-room.sensor-01.climate.temperature + +# All metrics (any device) +- metric +# or +- metric:* +``` + +### 6.5 Dashboards + +Dashboard **id** is often a **UUID** generated by the web UI. Policies match that id: + +```yaml +# All dashboards +- dashboard +# or +- dashboard:* + +# One dashboard (use the real id from GET /api/dashboard) +- dashboard:3f2a9c1e-8b4d-4e2f-9a1b-0c7d6e5f4a3b +``` + +The dashboard **title** is not used for authorization. + +Opening a dashboard only loads layout. Widgets still need `field`, `metric`, `quickid`, etc., as appropriate. + +### 6.6 Own profile + +`/api/user/profile` is allowed for the logged-in user without requiring the `user` resource (so people can change their own password/profile). + +Managing other users requires the `user` resource (typically `admin` or a custom identity policy). + +--- + +## 7. Management APIs and UI + +| Resource | API prefix | UI | +| -------------- | ------------------- | ------------------------- | +| Users | `/api/user` | Settings → Users | +| Policies | `/api/policy` | Settings → Policies | +| Service tokens | `/api/servicetoken` | Settings → Service Tokens | + +Only principals with policy rights on `user` / `policy` can manage them (e.g. built-in `admin`). + +--- + +### 8.0 Allow all operational, but deny settings and backup + +```yaml +id: operator-no-settings +statements: + - effect: Allow + actions: ["*"] + resources: ["*"] + - effect: Deny + actions: ["*"] + resources: + - settings + - backup + - user + - policy +``` + +### 8.0b Allow all gateways except one + +```yaml +statements: + - effect: Allow + actions: [get, list] + resources: [gateway:*] + - effect: Deny + actions: ["*"] + resources: [gateway:secret-gw] +``` + +List queries apply Deny as well: + +- **Id-keyed kinds** (`gateway`, `task`, …): exact Deny ids use `NotIn` on `id`. +- **Hierarchical kinds** (`node`, `source`, `field`): e.g. Deny `node:mysensor.1` excludes that node from list via `NOT (gatewayId=mysensor AND nodeId=1)`. + +Deny statements must include the **`list`** action (or `*`) to affect list results; a Deny with only `get` still blocks get-by-id but not list. + +## 8. Complete examples + +Example device layout used below: + +```text +Gateway id: plant-room + Node id: sensor-01 + Source: climate + Fields: temperature, humidity + Node id: pump-01 + Source: motor + Fields: running, runtime +Gateway id: workshop + ... +``` + +### 8.1 Full administrator + +Use built-in policy: + +```yaml +# user.policies +policies: [admin] +``` + +### 8.2 Operator (all devices, no user/policy admin) + +```yaml +policies: [readwrite] +``` + +### 8.3 Global read-only + +```yaml +policies: [readonly] +``` + +### 8.4 Single gateway, read-only, with charts and one dashboard + +```yaml +id: plant-room-viewer +description: View plant-room devices, metrics, and a shared dashboard +system: false +statements: + - effect: Allow + actions: [get, list] + resources: + - gateway:plant-room + - node:plant-room.* + - source:plant-room.* + - field:plant-room.* + - metric:plant-room.* + - dashboard:3f2a9c1e-8b4d-4e2f-9a1b-0c7d6e5f4a3b + - quickid + - status +``` + +Attach to a user: + +```yaml +username: alice +policies: [plant-room-viewer] +disabled: false +``` + +### 8.5 Control only the pump under plant-room + +```yaml +id: pump-operator +description: Control pump node only; read climate sensors +system: false +statements: + - effect: Allow + actions: [get, list] + resources: + - gateway:plant-room + - node:plant-room.sensor-01 + - source:plant-room.sensor-01.* + - field:plant-room.sensor-01.* + - metric:plant-room.sensor-01.* + - quickid + - effect: Allow + actions: [get, list, update, action, enable, disable] + resources: + - node:plant-room.pump-01 + - source:plant-room.pump-01.* + - field:plant-room.pump-01.* + - metric:plant-room.pump-01.* + - action +``` + +### 8.6 Metrics for one field only + +```yaml +id: temp-chart-only +statements: + - effect: Allow + actions: [get, list] + resources: + - field:plant-room.sensor-01.climate.temperature + - metric:plant-room.sensor-01.climate.temperature + - quickid +``` + +Without `metric:…`, field access alone does **not** open `/api/metric`. + +### 8.7 Identity administrator (users and policies only) + +```yaml +id: identity-admin +statements: + - effect: Allow + actions: ["*"] + resources: + - user + - policy +``` + +Combine with another policy if that person also needs device access. + +### 8.8 Service token narrower than the user + +User has `readwrite`. Token for automation: + +```yaml +name: plant-room-metrics-bot +userId: +neverExpire: false +expiresOn: "2027-12-31" +actions: + - get + - list +resources: + - field:plant-room.* + - metric:plant-room.* + - quickid +``` + +The bot cannot update devices or touch other gateways, even though Alice could. + +### 8.9 Disable a user + +```yaml +username: bob +disabled: true +policies: [readonly] +``` + +Existing JWTs for Bob are rejected on the next request (user cache updated on save). + +--- + +## 9. Client filters and policies together + +Clients may pass their own filters (example): + +```http +GET /api/field?limit=20&offset=0&filter=[{"k":"gatewayId","o":"eq","v":"plant-room"}] +``` + +Policy scope always applies as well. The result is the **intersection**: + +```text +rows matching client filters ∩ rows allowed by policy/token +``` + +Asking for a gateway outside the policy yields an **empty list**, not a way to expand access. +Denied kinds or verbs yield **403 Forbidden**. + +--- + +## 10. Migration and bootstrap + +| Situation | Behavior | +| ---------------------------------- | ------------------------------------------------------------------------- | +| Fresh install | Built-in policies created; default `admin` user gets policy `admin` | +| Upgrade from before access control | Patch `2.2.0-1` creates policies; users with empty `policies` get `admin` | +| HTTP startup | Built-in policies ensured (statements reset); users with no policies are **logged**, not granted anything | + +An empty `policies` list means **no access**, and it stays that way. Only the one-time +`2.2.0-1` upgrade grants `admin` to policy-less users, because on a pre-RBAC install every +existing user really was an administrator. Stripping a user's policies is therefore a valid way +to lock them out, and a restart will not undo it. + +Built-in policies (`admin`, `readwrite`, `readonly`) are code: their statements are rewritten on +every start, and the API rejects edits to them. Copy one into a new policy to customise it. + +### Deleting policies + +A policy that is still attached to a user cannot be deleted — the API returns the affected +usernames. Detach it first. This prevents a dangling policy id, and prevents a user's list from +silently becoming empty (which would remove all of their access). + +--- + +## 11. Performance notes + +- Users, policies, and service tokens used for auth are kept in an **in-memory cache**, refreshed on write. +- List scoping is applied as **storage query filters** (including OR of name patterns). +- Get-by-UUID for device entities does one lookup to resolve the business name before the policy check. + +--- + +## 12. Quick reference + +### Attach policy to user (conceptually) + +```yaml +# Policy +id: plant-room-viewer +statements: [...] + +# User +username: alice +policies: [plant-room-viewer] +``` + +### Minimal console-friendly viewer for one gateway + +```yaml +actions: [get, list] +resources: + - gateway:plant-room + - node:plant-room.* + - source:plant-room.* + - field:plant-room.* + - metric:plant-room.* + - dashboard # all dashboards; or dashboard: + - quickid + - status +``` + +### What not to confuse + +| Do not assume | Reality | +| --------------------------- | ---------------------------------- | +| `field:…` implies metrics | Need `metric:…` as well | +| Dashboard title in policy | Use dashboard **id** (often UUID) | +| Node UUID in policy | Use `gatewayId.nodeId` | +| Token can elevate rights | Token can only **narrow** the user | +| Disabled user keeps working | JWT rejected while disabled | + +--- + +## 13. Related code (for developers) + +| Area | Location | +| ----------------------------------------------- | --------------------------------------------- | +| Policy types | `pkg/types/policy` | +| Engine, match, list/query filters, metrics auth | `pkg/api/policy` | +| Middleware authz | `pkg/http_router/middleware/auth.go` | +| User / policy HTTP routes | `pkg/http_router/routes/user.go`, `policy.go` | +| Upgrade seed | `pkg/upgrade/v2_2_0__1.go` | +| Web UI | Settings → Users, Settings → Policies | + +--- + +## 14. Changelog (feature introduction) + +Policy-based access control was introduced for server release line **2.2.0** (upgrade id `2.2.0-1`): built-in policies, user `policies` / `disabled`, service token restrictions, and enforcement on the HTTP API. diff --git a/pkg/api/backup/api.go b/pkg/api/backup/api.go index 97779f8..5d9f497 100644 --- a/pkg/api/backup/api.go +++ b/pkg/api/backup/api.go @@ -34,6 +34,14 @@ func New(ctx context.Context, logger *zap.Logger, backupRestore *backupTY.Backup // List by filter and pagination func (bk *BackupAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { + if pagination == nil { + pagination = &storageTY.Pagination{ + Limit: 10, + Offset: 0, + SortBy: []storageTY.Sort{{Field: "id", OrderBy: storageTY.SortByASC}}, + } + } + files, err := bk.GetBackupFilesList() if err != nil { return nil, err @@ -42,14 +50,6 @@ func (bk *BackupAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagi finalList := make([]interface{}, 0) totalCount := int64(0) if len(files) > 0 { - if pagination == nil { - pagination = &storageTY.Pagination{ - Limit: 10, - Offset: 0, - SortBy: []storageTY.Sort{{Field: "id", OrderBy: storageTY.SortByASC}}, - } - } - // filter and then sort the files filteredFiles := filterUtils.Filter(files, filters, false) sortedFiles, count := filterUtils.Sort(filteredFiles, pagination) diff --git a/pkg/api/backup/api_impl.go b/pkg/api/backup/api_impl.go index a9eff02..c6831cf 100644 --- a/pkg/api/backup/api_impl.go +++ b/pkg/api/backup/api_impl.go @@ -63,42 +63,53 @@ func (bk *BackupAPI) RunOnDemandBackup(input *backupTY.OnDemandBackupConfig) err // GetBackupFilesList details func (bk *BackupAPI) GetBackupFilesList() ([]interface{}, error) { + exportedFiles := make([]interface{}, 0) + locationsSettings, err := bk.settingsAPI.GetBackupLocations() if err != nil { - return nil, err + // No locations configured (or settings missing): return empty list, not an error. + // UI calls GET /api/backup on the backup page; 500 here breaks the whole page. + bk.logger.Debug("backup locations not available", zap.Error(err)) + return exportedFiles, nil } locations := locationsSettings.Locations - exportedFiles := make([]interface{}, 0) - for _, location := range locations { - if location.Type == backupUtil.ProviderDisk { - diskLocation := &backupTY.BackupLocationDisk{} - err = utils.MapToStruct(utils.TagNameNone, location.Config, diskLocation) - if err != nil { - return exportedFiles, err - } - rawFiles, err := utils.ListFiles(diskLocation.TargetDirectory) - if err != nil { - return exportedFiles, err + if location.Type != backupUtil.ProviderDisk { + continue + } + diskLocation := &backupTY.BackupLocationDisk{} + err = utils.MapToStruct(utils.TagNameNone, location.Config, diskLocation) + if err != nil { + bk.logger.Warn("skip backup location: invalid config", zap.String("location", location.Name), zap.Error(err)) + continue + } + if strings.TrimSpace(diskLocation.TargetDirectory) == "" { + bk.logger.Debug("skip backup location: empty target directory", zap.String("location", location.Name)) + continue + } + rawFiles, err := utils.ListFiles(diskLocation.TargetDirectory) + if err != nil { + // Do not fail the whole list if one path is missing/unreadable + bk.logger.Warn("skip backup location: cannot list files", zap.String("location", location.Name), zap.String("dir", diskLocation.TargetDirectory), zap.Error(err)) + continue + } + for _, rawFile := range rawFiles { + if rawFile.IsDir || !strings.Contains(rawFile.Name, backupUtil.BackupIdentifier) { + continue } - for _, rawFile := range rawFiles { - if rawFile.IsDir || !strings.Contains(rawFile.Name, backupUtil.BackupIdentifier) { - continue - } - exportedFile := backupTY.BackupFile{ - ID: rawFile.FullPath, - LocationName: location.Name, - ProviderType: location.Type, - Directory: diskLocation.TargetDirectory, - FileName: rawFile.Name, - FileSize: rawFile.Size, - FullPath: rawFile.FullPath, - ModifiedOn: rawFile.ModifiedTime, - } - exportedFiles = append(exportedFiles, exportedFile) + exportedFile := backupTY.BackupFile{ + ID: rawFile.FullPath, + LocationName: location.Name, + ProviderType: location.Type, + Directory: diskLocation.TargetDirectory, + FileName: rawFile.Name, + FileSize: rawFile.Size, + FullPath: rawFile.FullPath, + ModifiedOn: rawFile.ModifiedTime, } + exportedFiles = append(exportedFiles, exportedFile) } } diff --git a/pkg/api/entities/api.go b/pkg/api/entities/api.go index 5274fa4..fecbaee 100644 --- a/pkg/api/entities/api.go +++ b/pkg/api/entities/api.go @@ -12,6 +12,7 @@ import ( gateway "github.com/mycontroller-org/server/v2/pkg/api/gateway" handler "github.com/mycontroller-org/server/v2/pkg/api/handler" node "github.com/mycontroller-org/server/v2/pkg/api/node" + policy "github.com/mycontroller-org/server/v2/pkg/api/policy" schedule "github.com/mycontroller-org/server/v2/pkg/api/schedule" serviceToken "github.com/mycontroller-org/server/v2/pkg/api/service_token" settings "github.com/mycontroller-org/server/v2/pkg/api/settings" @@ -116,6 +117,10 @@ func (a *API) Node() *node.NodeAPI { return node.New(a.ctx, a.logger, a.storage, a.bus) } +func (a *API) Policy() *policy.API { + return policy.New(a.ctx, a.logger, a.storage) +} + func (a *API) Schedule() *schedule.ScheduleAPI { return schedule.New(a.ctx, a.logger, a.storage, a.bus) } diff --git a/pkg/api/policy/action_auth.go b/pkg/api/policy/action_auth.go new file mode 100644 index 0000000..c238a00 --- /dev/null +++ b/pkg/api/policy/action_auth.go @@ -0,0 +1,131 @@ +package policy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + webHandlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" +) + +// maxActionBodyBytes caps the body we buffer while authorizing action requests. +const maxActionBodyBytes = 1 << 20 // 1 MiB + +// AuthorizeActionRequest enforces access for the /api/action* endpoints. +// +// These endpoints take their targets from the query string or the request body, +// so a bare "action" resource in a policy must not be enough - every target is +// checked individually (like quickid), otherwise /api/action becomes a write +// channel into every field/node in the system. +// +// GET /api/action/node?id=&id= -> action on node: for each id +// GET /api/action/gateway?id= -> action on gateway: for each id +// GET /api/action?resource= -> action on the quick id target +// POST /api/action [{resource: }] -> action on every quick id in the body +// +// Any denied target fails the whole request. +func (a *API) AuthorizeActionRequest(subject Subject, r *http.Request) error { + path := strings.TrimSuffix(r.URL.Path, "/") + + switch { + case strings.HasPrefix(path, "/api/action/node"): + return a.allowedActionOnIDs(subject, policyTY.ResourceNode, r.URL.Query()["id"]) + case strings.HasPrefix(path, "/api/action/gateway"): + return a.allowedActionOnIDs(subject, policyTY.ResourceGateway, r.URL.Query()["id"]) + } + + quickIDs := r.URL.Query()[keyResourceParam] + if r.Method == http.MethodPost { + bodyQuickIDs, err := a.actionQuickIDsFromBody(r) + if err != nil { + return err + } + quickIDs = append(quickIDs, bodyQuickIDs...) + } + + if len(quickIDs) == 0 { + // no identifiable target: require the generic action capability + return a.Allowed(subject, policyTY.ActionAction, policyTY.ResourceAction) + } + + checked := 0 + for _, quickID := range quickIDs { + quickID = strings.TrimSpace(quickID) + if quickID == "" { + continue + } + resource, err := ResourceFromQuickID(quickID) + if err != nil { + return err + } + if err := a.Allowed(subject, policyTY.ActionAction, resource); err != nil { + return fmt.Errorf("action on %s: %w", quickID, err) + } + checked++ + } + if checked == 0 { + return a.Allowed(subject, policyTY.ActionAction, policyTY.ResourceAction) + } + return nil +} + +// keyResourceParam matches routes/action.go +const keyResourceParam = "resource" + +// allowedActionOnIDs checks the action verb against each target id. Ids on these +// routes are storage ids, so they are resolved to business names first +// (node -> gatewayId.nodeId) to match the names used in policies. +func (a *API) allowedActionOnIDs(subject Subject, kind string, ids []string) error { + if len(ids) == 0 { + return a.Allowed(subject, policyTY.ActionAction, kind) + } + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + name := id + if biz, err := a.ResolveBusinessName(kind, id); err == nil && biz != "" { + name = biz + } + if err := a.Allowed(subject, policyTY.ActionAction, FormatResource(kind, name)); err != nil { + return fmt.Errorf("action on %s: %w", FormatResource(kind, name), err) + } + } + return nil +} + +// actionQuickIDsFromBody reads the POST /api/action payload and returns the +// quick ids it targets. The body is restored for the handler. +func (a *API) actionQuickIDsFromBody(r *http.Request) ([]string, error) { + if r.Body == nil { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxActionBodyBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxActionBodyBytes { + return nil, fmt.Errorf("action payload too large") + } + r.Body = io.NopCloser(bytes.NewReader(body)) + if len(body) == 0 { + return nil, nil + } + + actions := make([]webHandlerTY.ActionConfig, 0) + if err := json.Unmarshal(body, &actions); err != nil { + // malformed payload: nothing to target, handler will report the parse error. + // Fail closed here by requiring the generic action capability. + return nil, nil + } + quickIDs := make([]string, 0, len(actions)) + for _, axn := range actions { + quickIDs = append(quickIDs, axn.Resource) + } + return quickIDs, nil +} diff --git a/pkg/api/policy/api.go b/pkg/api/policy/api.go new file mode 100644 index 0000000..35edb8d --- /dev/null +++ b/pkg/api/policy/api.go @@ -0,0 +1,315 @@ +package policy + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + "github.com/mycontroller-org/server/v2/pkg/utils" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + "go.uber.org/zap" +) + +// singleton-ish cache shared across API instances for process lifetime +var ( + globalCache *Cache + globalCacheOnce sync.Once +) + +type API struct { + ctx context.Context + logger *zap.Logger + storage storageTY.Plugin + cache *Cache +} + +func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *API { + a := &API{ + ctx: ctx, + logger: logger.Named("policy_api"), + storage: storage, + cache: nil, + } + + // The cache is process wide and read by every request. Install the storage + // loaders exactly once: rebinding them on each New() (this runs per request, + // via entities.API.Policy()) would race with the readers. + globalCacheOnce.Do(func() { + globalCache = newCache() + globalCache.setLoaders( + func(id string) (*userTY.User, error) { + u, err := a.loadUserFromStorage(id) + if err != nil { + return nil, err + } + return &u, nil + }, + func(id string) (*policyTY.Policy, error) { + p, err := a.loadPolicyFromStorage(id) + if err != nil { + return nil, err + } + return &p, nil + }, + func(tokenID string) (*svcTokenTY.ServiceToken, error) { + t, err := a.loadTokenFromStorage(tokenID) + if err != nil { + return nil, err + } + return &t, nil + }, + func() ([]policyTY.Policy, error) { + return a.listAllPolicies() + }, + ) + }) + a.cache = globalCache + + return a +} + +// Cache exposes the in-memory cache for invalidation from other packages. +func (a *API) Cache() *Cache { + return a.cache +} + +// EnsureBuiltInPolicies creates system policies if missing and warms cache. +// Built-in statements are reset on every start: they are code, not user data. +func (a *API) EnsureBuiltInPolicies() error { + for _, p := range BuiltInPolicies() { + cp := p + cp.System = true + if err := a.saveSystemPolicy(&cp); err != nil { + return fmt.Errorf("ensure built-in policy %s: %w", p.ID, err) + } + } + return a.cache.WarmPolicies() +} + +// ReportUsersWithoutPolicies logs users that cannot access anything and warms the +// user cache. It deliberately does not grant anything: an empty policy list means +// "no access", and silently promoting such a user to admin on every restart would +// undo an administrator's decision. Pre-RBAC installs are handled once by the +// 2.2.0-1 upgrade (see AssignAdminToUsersWithoutPolicies). +func (a *API) ReportUsersWithoutPolicies() error { + users, err := a.listAllUsers() + if err != nil { + return err + } + for i := range users { + u := users[i] + a.cache.PutUser(&u) + if len(u.Policies) == 0 { + a.logger.Warn("user has no policies attached and cannot access any api", + zap.String("userId", u.ID), zap.String("username", u.Username)) + } + } + return nil +} + +// AssignAdminToUsersWithoutPolicies grants the admin policy to users that have none. +// Only for the pre-RBAC migration, where every existing user was effectively an admin. +func (a *API) AssignAdminToUsersWithoutPolicies() error { + users, err := a.listAllUsers() + if err != nil { + return err + } + for i := range users { + u := users[i] + if len(u.Policies) > 0 { + a.cache.PutUser(&u) + continue + } + u.Policies = []string{policyTY.PolicyAdmin} + if err := a.storage.Upsert(types.EntityUser, &u, []storageTY.Filter{{Key: types.KeyID, Value: u.ID}}); err != nil { + return err + } + a.cache.PutUser(&u) + a.logger.Info("assigned admin policy to pre-rbac user", + zap.String("userId", u.ID), zap.String("username", u.Username)) + } + return nil +} + +func (a *API) listAllUsers() ([]userTY.User, error) { + result := make([]userTY.User, 0) + if _, err := a.storage.Find(types.EntityUser, &result, nil, &storageTY.Pagination{Limit: -1}); err != nil { + return nil, err + } + return result, nil +} + +func (a *API) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { + result := make([]policyTY.Policy, 0) + return a.storage.Find(types.EntityPolicy, &result, filters, pagination) +} + +func (a *API) GetByID(id string) (policyTY.Policy, error) { + return a.loadPolicyFromStorage(id) +} + +func (a *API) loadPolicyFromStorage(id string) (policyTY.Policy, error) { + result := policyTY.Policy{} + err := a.storage.FindOne(types.EntityPolicy, &result, []storageTY.Filter{{Key: types.KeyID, Value: id}}) + return result, err +} + +func (a *API) loadUserFromStorage(id string) (userTY.User, error) { + result := userTY.User{} + err := a.storage.FindOne(types.EntityUser, &result, []storageTY.Filter{{Key: types.KeyID, Value: id}}) + return result, err +} + +func (a *API) loadTokenFromStorage(tokenID string) (svcTokenTY.ServiceToken, error) { + result := svcTokenTY.ServiceToken{} + err := a.storage.FindOne(types.EntityServiceToken, &result, []storageTY.Filter{{Key: types.KeyTokenID, Value: tokenID}}) + return result, err +} + +func (a *API) listAllPolicies() ([]policyTY.Policy, error) { + result := make([]policyTY.Policy, 0) + _, err := a.storage.Find(types.EntityPolicy, &result, nil, &storageTY.Pagination{Limit: -1}) + return result, err +} + +// Save persists a user authored policy. +// +// System policies are code: their statements are rewritten on every start, so +// accepting edits here would silently discard them. The System flag itself is never +// taken from the request - otherwise a client could mark its own policy +// undeletable. +func (a *API) Save(policy *policyTY.Policy) error { + if policy.ID == "" { + policy.ID = utils.RandID() + } + existing, err := a.loadPolicyFromStorage(policy.ID) + isExisting := err == nil && existing.ID != "" + + if isExisting && existing.System { + return fmt.Errorf("cannot modify system policy: %s", policy.ID) + } + policy.System = false + + return a.upsert(policy) +} + +// saveSystemPolicy persists a built-in policy. Internal use only. +func (a *API) saveSystemPolicy(policy *policyTY.Policy) error { + policy.System = true + return a.upsert(policy) +} + +func (a *API) upsert(policy *policyTY.Policy) error { + policy.ModifiedOn = time.Now() + filters := []storageTY.Filter{{Key: types.KeyID, Value: policy.ID}} + if err := a.storage.Upsert(types.EntityPolicy, policy, filters); err != nil { + return err + } + a.cache.PutPolicy(policy) + return nil +} + +// Delete removes policies. System policies are protected, and so are policies still +// attached to a user - detaching them silently would leave that user with an +// unresolvable policy id, and a user whose list becomes empty loses all access. +func (a *API) Delete(IDs []string) (int64, error) { + for _, id := range IDs { + p, err := a.loadPolicyFromStorage(id) + if err == nil && p.System { + return 0, fmt.Errorf("cannot delete system policy: %s", id) + } + } + + users, err := a.listAllUsers() + if err != nil { + return 0, err + } + for _, id := range IDs { + var attached []string + for i := range users { + if utils.ContainsString(users[i].Policies, id) { + attached = append(attached, users[i].Username) + } + } + if len(attached) > 0 { + return 0, fmt.Errorf("policy %s is attached to user(s): %s", id, strings.Join(attached, ", ")) + } + } + + filters := []storageTY.Filter{{Key: types.KeyID, Operator: storageTY.OperatorIn, Value: IDs}} + n, err := a.storage.Delete(types.EntityPolicy, filters) + if err != nil { + return n, err + } + for _, id := range IDs { + a.cache.InvalidatePolicy(id) + } + return n, nil +} + +func (a *API) Import(data interface{}) error { + input, ok := data.(policyTY.Policy) + if !ok { + return fmt.Errorf("invalid type:%T", data) + } + if input.ID == "" { + input.ID = utils.RandID() + } + filters := []storageTY.Filter{{Key: types.KeyID, Value: input.ID}} + if err := a.storage.Upsert(types.EntityPolicy, &input, filters); err != nil { + return err + } + a.cache.PutPolicy(&input) + return nil +} + +func (a *API) GetEntityInterface() interface{} { + return policyTY.Policy{} +} + +// NotifyUserUpdated refreshes user cache after user write. +func (a *API) NotifyUserUpdated(user *userTY.User) { + a.cache.PutUser(user) +} + +// NotifyUserDeleted removes user from cache. +func (a *API) NotifyUserDeleted(id string) { + a.cache.InvalidateUser(id) +} + +// NotifyTokenUpdated refreshes token cache. +func (a *API) NotifyTokenUpdated(token *svcTokenTY.ServiceToken) { + a.cache.PutToken(token) +} + +// NotifyTokenDeleted invalidates token cache. +func (a *API) NotifyTokenDeleted(entityID, tokenID string) { + if tokenID != "" { + a.cache.InvalidateToken(tokenID) + } + if entityID != "" { + a.cache.InvalidateTokenByEntityID(entityID) + } +} + +// ValidatePoliciesExist ensures all policy ids exist. +func (a *API) ValidatePoliciesExist(ids []string) error { + for _, id := range ids { + if _, err := a.cache.GetPolicy(id); err != nil { + // try storage + if _, err2 := a.loadPolicyFromStorage(id); err2 != nil { + return fmt.Errorf("policy not found: %s", id) + } + } + } + return nil +} + +var ErrNotFound = errors.New("not found") diff --git a/pkg/api/policy/body_auth.go b/pkg/api/policy/body_auth.go new file mode 100644 index 0000000..f14bb2a --- /dev/null +++ b/pkg/api/policy/body_auth.go @@ -0,0 +1,244 @@ +package policy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +// maxBodyPeekBytes caps how much of a write body is buffered for authorization. +const maxBodyPeekBytes = 4 << 20 // 4 MiB + +// bodyTarget carries every identity field a write payload may use to name its +// target. Entity payloads across the api are uniform: "id" for id-keyed kinds and +// the gateway/node/source/field chain for the device tree. +type bodyTarget struct { + ID string `json:"id"` + GatewayID string `json:"gatewayId"` + NodeID string `json:"nodeId"` + SourceID string `json:"sourceId"` + FieldID string `json:"fieldId"` +} + +// name returns the policy resource name this payload targets, for the given kind. +func (b bodyTarget) name(kind string) string { + switch kind { + case policyTY.ResourceNode: + if path := joinIDs(b.GatewayID, b.NodeID); path != "" { + return path + } + case policyTY.ResourceSource: + if path := joinIDs(b.GatewayID, b.NodeID, b.SourceID); path != "" { + return path + } + case policyTY.ResourceField: + if path := joinIDs(b.GatewayID, b.NodeID, b.SourceID, b.FieldID); path != "" { + return path + } + } + return b.ID +} + +// AuthorizeBodyTargets authorizes the objects named in a write payload. +// +// The path-based check in the middleware can only see the kind for collection +// endpoints - POST /api/gateway, POST /api/gateway/enable and DELETE /api/gateway +// all carry their targets in the body. A kind-only check is deliberately permissive +// (it answers "may you reach this endpoint?"), so without this second pass a grant +// on one named object would authorize writes to every object of that kind. +// +// Two payload shapes cover the whole api: +// +// {"id": "gw1", ...} one entity -> check kind:gw1 +// ["gw1", "gw2"] bulk ids -> check kind:gw1 and kind:gw2 +// +// A payload that names no target (create with a server generated id) requires a +// kind-wide grant instead. Anything else falls back to the kind-level decision +// already made by the caller. +// +// The body is restored so the handler can read it. +func (a *API) AuthorizeBodyTargets(subject Subject, r *http.Request, access *RequestAccess) error { + if access == nil || access.Kind == "" || access.Skip { + return nil + } + switch r.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + default: + return nil + } + // the path already named the target and it has been checked. This is also the + // only place uploads arrive (POST /api/firmware/upload/{id}), so no large or + // streaming body is ever buffered here. + if access.Name != "" { + return nil + } + + // Deliberately not keyed on Content-Type: handlers json.Unmarshal the body + // whatever the header says, so trusting it would let a client skip this check + // by sending "text/plain". + body, err := a.peekBody(r) + if err != nil { + return err + } + if len(bytes.TrimSpace(body)) == 0 { + return nil + } + + switch firstJSONToken(body) { + case '[': + return a.authorizeIDList(subject, body, access) + case '{': + return a.authorizeEntity(subject, body, access) + default: + // not a json document: the handler cannot decode it either, so it cannot + // write anything. The kind level decision stands. + return nil + } +} + +// authorizeIDList handles bulk id payloads (enable / disable / reload / delete). +func (a *API) authorizeIDList(subject Subject, body []byte, access *RequestAccess) error { + var ids []string + if err := json.Unmarshal(body, &ids); err != nil { + // not a list of ids (e.g. a list of objects): the kind level decision stands. + // Endpoints that take structured lists of targets are authorized explicitly + // (see AuthorizeActionRequest). + return nil + } + if len(ids) == 0 { + return nil + } + // for id-keyed kinds the id *is* the policy name, so no lookup is needed + resolve := !isIDKeyedKind(access.Kind) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if err := a.allowedTarget(subject, access.Action, access.Kind, id, resolve); err != nil { + return err + } + } + return nil +} + +// authorizeEntity handles single entity payloads. +func (a *API) authorizeEntity(subject Subject, body []byte, access *RequestAccess) error { + target := bodyTarget{} + if err := json.Unmarshal(body, &target); err != nil { + // malformed or unexpected shape: the handler will reject it. Require the + // stronger kind-wide grant rather than trusting the kind level decision. + return a.AllowedKindWide(subject, access.Action, access.Kind) + } + + if access.Kind == policyTY.ResourceUser { + if err := a.authorizeUserPrivilegedFields(subject, body, access); err != nil { + return err + } + } + + name := strings.TrimSpace(target.name(access.Kind)) + if name == "" { + // no target to check (create with a server generated id): a grant on some + // other named object must not be enough + return a.AllowedKindWide(subject, access.Action, access.Kind) + } + // A client supplied name is not necessarily an existing object, so only resolve + // storage ids for the device tree, where the payload may carry a bare uuid. + return a.allowedTarget(subject, access.Action, access.Kind, name, !isIDKeyedKind(access.Kind)) +} + +// authorizeUserPrivilegedFields requires a kind-wide user grant to change +// policies or disabled. Assigning the built-in admin policy additionally +// requires that the caller already holds equivalent full access. +func (a *API) authorizeUserPrivilegedFields(subject Subject, body []byte, access *RequestAccess) error { + raw := map[string]json.RawMessage{} + if err := json.Unmarshal(body, &raw); err != nil { + return a.AllowedKindWide(subject, access.Action, policyTY.ResourceUser) + } + if _, ok := raw["policies"]; ok { + if err := a.AllowedKindWide(subject, access.Action, policyTY.ResourceUser); err != nil { + return fmt.Errorf("update user policies: %w", err) + } + } + if _, ok := raw["disabled"]; ok { + if err := a.AllowedKindWide(subject, access.Action, policyTY.ResourceUser); err != nil { + return fmt.Errorf("update user disabled: %w", err) + } + } + if userPayloadAssignsAdmin(raw) && !a.subjectHoldsAdmin(subject) { + return fmt.Errorf("assigning admin policy: %w", ErrAccessDenied) + } + return nil +} + +func userPayloadAssignsAdmin(raw map[string]json.RawMessage) bool { + msg, ok := raw["policies"] + if !ok { + return false + } + var policies []string + if err := json.Unmarshal(msg, &policies); err != nil { + return true + } + for _, id := range policies { + if strings.TrimSpace(id) == policyTY.PolicyAdmin { + return true + } + } + return false +} + +func (a *API) subjectHoldsAdmin(subject Subject) bool { + user, err := a.activeUser(subject) + if err != nil { + return false + } + return a.policiesAllow(user.Policies, policyTY.ActionAll, policyTY.ResourceAll) +} + +// allowedTarget checks action on kind:. When resolve is set and the name is a +// storage id, it is translated to the business name used in policies +// (uuid -> gatewayId.nodeId...). +func (a *API) allowedTarget(subject Subject, action, kind, name string, resolve bool) error { + if resolve && !strings.Contains(name, ".") { + if biz, err := a.ResolveBusinessName(kind, name); err == nil && biz != "" { + name = biz + } + } + resource := FormatResource(kind, name) + if err := a.Allowed(subject, action, resource); err != nil { + return fmt.Errorf("%s on %s: %w", action, resource, err) + } + return nil +} + +// peekBody buffers the request body for inspection and puts it back for the handler. +func (a *API) peekBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyPeekBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxBodyPeekBytes { + return nil, fmt.Errorf("request payload too large") + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return body, nil +} + +// firstJSONToken returns the first meaningful byte of a json document. +func firstJSONToken(body []byte) byte { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return 0 + } + return trimmed[0] +} diff --git a/pkg/api/policy/body_auth_test.go b/pkg/api/policy/body_auth_test.go new file mode 100644 index 0000000..a2ad455 --- /dev/null +++ b/pkg/api/policy/body_auth_test.go @@ -0,0 +1,247 @@ +package policy + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" +) + +func apiWithPolicies(t *testing.T, policies ...policyTY.Policy) *API { + t.Helper() + c := newCache() + ids := make([]string, 0, len(policies)) + for i := range policies { + cp := policies[i] + c.PutPolicy(&cp) + ids = append(ids, cp.ID) + } + c.PutUser(&userTY.User{ID: "u1", Username: "u", Policies: ids}) + c.setLoaders( + func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, + func(id string) (*policyTY.Policy, error) { return nil, ErrNotFound }, + func(tokenID string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func() ([]policyTY.Policy, error) { return nil, nil }, + ) + return &API{cache: c} +} + +// one named gateway, full verbs on it +func singleGatewayPolicy() policyTY.Policy { + return policyTY.Policy{ + ID: "one-gateway", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"gateway:gw1"}, + }}, + } +} + +func writeRequest(t *testing.T, method, path, body string) *http.Request { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + return r +} + +// A grant on one named object must not authorize writes to its siblings, even +// though the collection endpoint itself is reachable. +func TestAuthorizeBodyTargets_NamedGrantDoesNotEscapeToSiblings(t *testing.T) { + a := apiWithPolicies(t, singleGatewayPolicy()) + subject := Subject{UserID: "u1"} + + cases := []struct { + name string + method string + path string + body string + allowed bool + }{ + {"update own gateway", http.MethodPost, "/api/gateway", `{"id":"gw1","name":"a"}`, true}, + {"update other gateway", http.MethodPost, "/api/gateway", `{"id":"gw2","name":"a"}`, false}, + {"enable own gateway", http.MethodPost, "/api/gateway/enable", `["gw1"]`, true}, + {"enable other gateway", http.MethodPost, "/api/gateway/enable", `["gw2"]`, false}, + {"enable own + other", http.MethodPost, "/api/gateway/enable", `["gw1","gw2"]`, false}, + {"delete own gateway", http.MethodDelete, "/api/gateway", `["gw1"]`, true}, + {"delete other gateway", http.MethodDelete, "/api/gateway", `["gw2"]`, false}, + // no target in the payload: needs a kind-wide grant, which this policy lacks + {"create with generated id", http.MethodPost, "/api/gateway", `{"name":"a"}`, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := writeRequest(t, tc.method, tc.path, tc.body) + access := MapRequest(r) + + // the coarse gate must stay permissive, or the object level check + // would never run + if err := a.Allowed(subject, access.Action, access.Resource); err != nil { + t.Fatalf("collection gate denied the request: %v", err) + } + + err := a.AuthorizeBodyTargets(subject, r, &access) + if tc.allowed && err != nil { + t.Fatalf("expected allowed, got %v", err) + } + if !tc.allowed && err == nil { + t.Fatal("expected denied, got allowed") + } + + // the handler must still be able to read the payload + body := make([]byte, len(tc.body)) + n, _ := r.Body.Read(body) + if string(body[:n]) != tc.body { + t.Fatalf("body not restored: %q", string(body[:n])) + } + }) + } +} + +// A kind-wide grant keeps working for every shape, including create. +func TestAuthorizeBodyTargets_KindWideGrant(t *testing.T) { + a := apiWithPolicies(t, policyTY.Policy{ + ID: "all-gateways", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{policyTY.ResourceGateway}, + }}, + }) + subject := Subject{UserID: "u1"} + + bodies := []string{`{"id":"gw2"}`, `{"name":"new"}`, `["gw1","gw2","gw3"]`} + for _, body := range bodies { + r := writeRequest(t, http.MethodPost, "/api/gateway", body) + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err != nil { + t.Fatalf("body %s: expected allowed, got %v", body, err) + } + } +} + +// Device tree payloads name their target through the gateway/node/source/field +// chain rather than the storage id. +func TestAuthorizeBodyTargets_DeviceTreePath(t *testing.T) { + a := apiWithPolicies(t, policyTY.Policy{ + ID: "one-node", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"node:gw1.n1"}, + }}, + }) + subject := Subject{UserID: "u1"} + + allowed := writeRequest(t, http.MethodPost, "/api/field", + `{"gatewayId":"gw1","nodeId":"n1","sourceId":"s1","fieldId":"temp"}`) + access := MapRequest(allowed) + if err := a.AuthorizeBodyTargets(subject, allowed, &access); err != nil { + t.Fatalf("field under the allowed node: %v", err) + } + + denied := writeRequest(t, http.MethodPost, "/api/field", + `{"gatewayId":"gw1","nodeId":"n2","sourceId":"s1","fieldId":"temp"}`) + access = MapRequest(denied) + if err := a.AuthorizeBodyTargets(subject, denied, &access); err == nil { + t.Fatal("field under another node must be denied") + } +} + +// An explicit deny on one object survives the body level check. +func TestAuthorizeBodyTargets_DenyWins(t *testing.T) { + a := apiWithPolicies(t, policyTY.Policy{ + ID: "all-but-one", + Statements: []policyTY.Statement{ + {Effect: policyTY.EffectAllow, Actions: []string{"*"}, Resources: []string{policyTY.ResourceGateway}}, + {Effect: policyTY.EffectDeny, Actions: []string{"*"}, Resources: []string{"gateway:critical"}}, + }, + }) + subject := Subject{UserID: "u1"} + + r := writeRequest(t, http.MethodDelete, "/api/gateway", `["gw1","critical"]`) + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err == nil { + t.Fatal("deny on gateway:critical must block the bulk delete") + } +} + +// Uploads name their target in the path, so their body is never buffered. +func TestAuthorizeBodyTargets_SkipsPathNamedUpload(t *testing.T) { + a := apiWithPolicies(t, singleGatewayPolicy()) + subject := Subject{UserID: "u1"} + + r := httptest.NewRequest(http.MethodPost, "/api/firmware/upload/fw1", strings.NewReader("binary-blob")) + r.Header.Set("Content-Type", "multipart/form-data; boundary=x") + access := MapRequest(r) + if access.Name != "fw1" { + t.Fatalf("expected the path to name the target, got %q", access.Name) + } + if err := a.AuthorizeBodyTargets(subject, r, &access); err != nil { + t.Fatalf("upload must not be body checked: %v", err) + } +} + +// Content-Type must not be a way around the object level check: handlers decode +// json whatever the header claims. +func TestAuthorizeBodyTargets_ContentTypeCannotBypass(t *testing.T) { + a := apiWithPolicies(t, singleGatewayPolicy()) + subject := Subject{UserID: "u1"} + + for _, contentType := range []string{"", "text/plain", "application/x-www-form-urlencoded", "application/json"} { + r := httptest.NewRequest(http.MethodPost, "/api/gateway", strings.NewReader(`{"id":"gw2"}`)) + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err == nil { + t.Fatalf("content-type %q bypassed the object level check", contentType) + } + } +} + +// A policy naming a single user must not be able to create or edit other users. +func TestAuthorizeBodyTargets_UserEscalation(t *testing.T) { + a := apiWithPolicies(t, policyTY.Policy{ + ID: "self-service", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{policyTY.ActionGet, policyTY.ActionUpdate}, + Resources: []string{"user:u1"}, + }}, + }) + subject := Subject{UserID: "u1"} + + for _, body := range []string{ + `{"id":"admin-user-id","policies":["admin"]}`, // hijack another user + `{"username":"new","policies":["admin"]}`, // create a new admin + } { + r := writeRequest(t, http.MethodPost, "/api/user", body) + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err == nil { + t.Fatalf("expected denied for body %s", body) + } + } + + // its own record is still writable + r := writeRequest(t, http.MethodPost, "/api/user", `{"id":"u1","fullName":"me"}`) + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err != nil { + t.Fatalf("own user record: %v", err) + } + + for _, body := range []string{ + `{"id":"u1","policies":["admin"]}`, + `{"id":"u1","disabled":true}`, + } { + r := writeRequest(t, http.MethodPost, "/api/user", body) + access := MapRequest(r) + if err := a.AuthorizeBodyTargets(subject, r, &access); err == nil { + t.Fatalf("expected privileged field denied for body %s", body) + } + } +} diff --git a/pkg/api/policy/cache.go b/pkg/api/policy/cache.go new file mode 100644 index 0000000..7e97f21 --- /dev/null +++ b/pkg/api/policy/cache.go @@ -0,0 +1,229 @@ +package policy + +import ( + "sync" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" +) + +// Cache holds users, policies, and service tokens in memory for fast auth checks. +// Call Invalidate* after any write so the next request reloads from storage. +type Cache struct { + mu sync.RWMutex + + users map[string]*userTY.User // by user id + policies map[string]*policyTY.Policy // by policy id + tokens map[string]*svcTokenTY.ServiceToken // by token.Token.ID (token id used in JWT) + + // loaders - set by API + loadUser func(id string) (*userTY.User, error) + loadPolicy func(id string) (*policyTY.Policy, error) + loadToken func(tokenID string) (*svcTokenTY.ServiceToken, error) + loadAllPol func() ([]policyTY.Policy, error) +} + +func newCache() *Cache { + return &Cache{ + users: make(map[string]*userTY.User), + policies: make(map[string]*policyTY.Policy), + tokens: make(map[string]*svcTokenTY.ServiceToken), + } +} + +func (c *Cache) setLoaders( + loadUser func(id string) (*userTY.User, error), + loadPolicy func(id string) (*policyTY.Policy, error), + loadToken func(tokenID string) (*svcTokenTY.ServiceToken, error), + loadAllPol func() ([]policyTY.Policy, error), +) { + c.mu.Lock() + defer c.mu.Unlock() + c.loadUser = loadUser + c.loadPolicy = loadPolicy + c.loadToken = loadToken + c.loadAllPol = loadAllPol +} + +func (c *Cache) userLoader() func(id string) (*userTY.User, error) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.loadUser +} + +func (c *Cache) policyLoader() func(id string) (*policyTY.Policy, error) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.loadPolicy +} + +func (c *Cache) tokenLoader() func(tokenID string) (*svcTokenTY.ServiceToken, error) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.loadToken +} + +// GetUser returns a cached copy or loads from storage. +func (c *Cache) GetUser(id string) (*userTY.User, error) { + c.mu.RLock() + if u, ok := c.users[id]; ok { + cp := *u + c.mu.RUnlock() + return &cp, nil + } + c.mu.RUnlock() + + load := c.userLoader() + if load == nil { + return nil, errCacheNotReady + } + u, err := load(id) + if err != nil { + return nil, err + } + c.mu.Lock() + c.users[id] = u + c.mu.Unlock() + cp := *u + return &cp, nil +} + +// GetPolicy returns a cached policy or loads it. +func (c *Cache) GetPolicy(id string) (*policyTY.Policy, error) { + c.mu.RLock() + if p, ok := c.policies[id]; ok { + cp := *p + c.mu.RUnlock() + return &cp, nil + } + c.mu.RUnlock() + + load := c.policyLoader() + if load == nil { + return nil, errCacheNotReady + } + p, err := load(id) + if err != nil { + return nil, err + } + c.mu.Lock() + c.policies[id] = p + c.mu.Unlock() + cp := *p + return &cp, nil +} + +// GetToken returns a cached service token by raw token id (Token.ID), or loads it. +func (c *Cache) GetToken(tokenID string) (*svcTokenTY.ServiceToken, error) { + c.mu.RLock() + if t, ok := c.tokens[tokenID]; ok { + cp := *t + c.mu.RUnlock() + return &cp, nil + } + c.mu.RUnlock() + + load := c.tokenLoader() + if load == nil { + return nil, errCacheNotReady + } + t, err := load(tokenID) + if err != nil { + return nil, err + } + c.mu.Lock() + c.tokens[tokenID] = t + c.mu.Unlock() + cp := *t + return &cp, nil +} + +// PutUser updates the cache after a write. +func (c *Cache) PutUser(u *userTY.User) { + if u == nil || u.ID == "" { + return + } + cp := *u + c.mu.Lock() + c.users[u.ID] = &cp + c.mu.Unlock() +} + +// PutPolicy updates the cache after a write. +func (c *Cache) PutPolicy(p *policyTY.Policy) { + if p == nil || p.ID == "" { + return + } + cp := *p + c.mu.Lock() + c.policies[p.ID] = &cp + c.mu.Unlock() +} + +// PutToken updates the cache after a write (keyed by Token.ID). +func (c *Cache) PutToken(t *svcTokenTY.ServiceToken) { + if t == nil { + return + } + cp := *t + c.mu.Lock() + if t.Token.ID != "" { + c.tokens[t.Token.ID] = &cp + } + c.mu.Unlock() +} + +// InvalidateUser drops a user from cache (call on delete). +func (c *Cache) InvalidateUser(id string) { + c.mu.Lock() + delete(c.users, id) + c.mu.Unlock() +} + +// InvalidatePolicy drops a policy from cache. +func (c *Cache) InvalidatePolicy(id string) { + c.mu.Lock() + delete(c.policies, id) + c.mu.Unlock() +} + +// InvalidateToken drops a service token from cache by Token.ID. +func (c *Cache) InvalidateToken(tokenID string) { + c.mu.Lock() + delete(c.tokens, tokenID) + c.mu.Unlock() +} + +// InvalidateTokenByEntityID removes any cached token matching entity id. +func (c *Cache) InvalidateTokenByEntityID(entityID string) { + c.mu.Lock() + for k, t := range c.tokens { + if t.ID == entityID { + delete(c.tokens, k) + } + } + c.mu.Unlock() +} + +// WarmPolicies loads all policies into cache (startup). +func (c *Cache) WarmPolicies() error { + c.mu.RLock() + load := c.loadAllPol + c.mu.RUnlock() + if load == nil { + return nil + } + list, err := load() + if err != nil { + return err + } + c.mu.Lock() + for i := range list { + p := list[i] + cp := p + c.policies[p.ID] = &cp + } + c.mu.Unlock() + return nil +} diff --git a/pkg/api/policy/defaults.go b/pkg/api/policy/defaults.go new file mode 100644 index 0000000..c468c74 --- /dev/null +++ b/pkg/api/policy/defaults.go @@ -0,0 +1,67 @@ +package policy + +import ( + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + settingsTY "github.com/mycontroller-org/server/v2/pkg/types/settings" +) + +// BuiltInPolicies returns system policies that should always exist. +func BuiltInPolicies() []policyTY.Policy { + allActions := []string{policyTY.ActionAll} + allResources := []string{policyTY.ResourceAll} + + rwActions := []string{ + policyTY.ActionGet, policyTY.ActionList, policyTY.ActionCreate, policyTY.ActionUpdate, + policyTY.ActionDelete, policyTY.ActionEnable, policyTY.ActionDisable, policyTY.ActionReload, + policyTY.ActionAction, + } + // operational resources (no user/policy/settings/backup admin) + rwResources := []string{ + policyTY.ResourceGateway, policyTY.ResourceNode, policyTY.ResourceSource, policyTY.ResourceField, + policyTY.ResourceTask, policyTY.ResourceSchedule, policyTY.ResourceHandler, policyTY.ResourceDashboard, + policyTY.ResourceFirmware, policyTY.ResourceForwardPayload, policyTY.ResourceDataRepository, + policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, policyTY.ResourceServiceToken, + policyTY.ResourceMetric, policyTY.ResourceAction, policyTY.ResourceStatus, policyTY.ResourceQuickID, + } + + roActions := []string{policyTY.ActionGet, policyTY.ActionList} + roResources := append([]string{}, rwResources...) + + // The console reads the system settings document (units, page size, ...) on every + // page. Grant only that one settings key: backup locations may hold credentials + // and the dynamic secrets key can reset the jwt secret. + readSettings := policyTY.Statement{ + Effect: policyTY.EffectAllow, + Actions: []string{policyTY.ActionGet}, + Resources: []string{policyTY.FormatSettingsResource(settingsTY.KeySystemSettings)}, + } + + return []policyTY.Policy{ + { + ID: policyTY.PolicyAdmin, + Description: "Full access to all resources and actions", + System: true, + Statements: []policyTY.Statement{ + {Effect: policyTY.EffectAllow, Actions: allActions, Resources: allResources}, + }, + }, + { + ID: policyTY.PolicyReadWrite, + Description: "Read and write operational resources; no user/policy/settings/backup admin", + System: true, + Statements: []policyTY.Statement{ + {Effect: policyTY.EffectAllow, Actions: rwActions, Resources: rwResources}, + readSettings, + }, + }, + { + ID: policyTY.PolicyReadOnly, + Description: "Read-only access to operational resources", + System: true, + Statements: []policyTY.Statement{ + {Effect: policyTY.EffectAllow, Actions: roActions, Resources: roResources}, + readSettings, + }, + }, + } +} diff --git a/pkg/api/policy/engine.go b/pkg/api/policy/engine.go new file mode 100644 index 0000000..4586147 --- /dev/null +++ b/pkg/api/policy/engine.go @@ -0,0 +1,609 @@ +package policy + +import ( + "errors" + "strings" + "time" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" +) + +var ( + errCacheNotReady = errors.New("access control cache not ready") + ErrUserDisabled = errors.New("user is disabled") + ErrUserNotFound = errors.New("user not found") + ErrTokenExpired = errors.New("service token expired") + ErrTokenNotFound = errors.New("service token not found") + ErrAccessDenied = errors.New("access denied") +) + +// Subject is the authenticated principal for an access check. +type Subject struct { + UserID string + ServiceTokenID string // raw Token.ID from JWT; empty for interactive login +} + +// Allowed reports whether the subject may perform action on resource. +// resource should be FormatResource(kind, name) e.g. "field:gw.n.s.f" or "gateway" for list-all. +// +// Rules: +// 1. User must exist and not be disabled +// 2. If service token: must exist, not expired, belong to user +// 3. User policies must allow (ceiling) +// 4. If token has restrictions, they must also allow (can only lower) +func (a *API) Allowed(subject Subject, action, resource string) error { + user, err := a.activeUser(subject) + if err != nil { + return err + } + token, err := a.activeToken(subject) + if err != nil { + return err + } + + if !a.policiesAllow(user.Policies, action, resource) { + return ErrAccessDenied + } + + // Token restrictions (optional lower bound): can only narrow further + if token != nil && (len(token.Actions) > 0 || len(token.Resources) > 0) { + if !restrictionsAllow(token.Actions, token.Resources, action, resource) { + return ErrAccessDenied + } + } + + return nil +} + +// activeUser loads the subject's user and verifies it can be used for authorization. +// A user with no attached policies has no access at all. +func (a *API) activeUser(subject Subject) (*userTY.User, error) { + if subject.UserID == "" { + return nil, ErrUserNotFound + } + user, err := a.cache.GetUser(subject.UserID) + if err != nil { + return nil, ErrUserNotFound + } + if user.Disabled { + return nil, ErrUserDisabled + } + if len(user.Policies) == 0 { + return nil, ErrAccessDenied + } + return user, nil +} + +// activeToken loads the subject's service token, if the request presented one. +// Returns (nil, nil) for an interactive login. +func (a *API) activeToken(subject Subject) (*svcTokenTY.ServiceToken, error) { + if subject.ServiceTokenID == "" { + return nil, nil + } + token, err := a.cache.GetToken(subject.ServiceTokenID) + if err != nil { + return nil, ErrTokenNotFound + } + if token.UserID != subject.UserID { + return nil, ErrAccessDenied + } + if err := validateTokenExpiry(token); err != nil { + return nil, err + } + return token, nil +} + +// AllowedKindWide reports whether the subject may perform action on *any* object of +// kind, i.e. it holds a kind-wide grant ("*", "kind" or "kind:*"), not merely a +// grant on some named object. +// +// Needed for writes whose target cannot be named in advance - creating an object +// with a server generated id. Without this, "Allow update on user:self" would be +// enough to create new users, because a kind-only resource check is deliberately +// permissive (it answers "may you reach this collection endpoint?"). +func (a *API) AllowedKindWide(subject Subject, action, kind string) error { + user, err := a.activeUser(subject) + if err != nil { + return err + } + token, err := a.activeToken(subject) + if err != nil { + return err + } + + if !a.policiesAllowKind(user.Policies, action, kind, true) { + return ErrAccessDenied + } + // A token restriction naming individual objects cannot satisfy a kind-wide check + if token != nil && (len(token.Actions) > 0 || len(token.Resources) > 0) { + if len(token.Actions) > 0 && !anyActionMatch(token.Actions, action) { + return ErrAccessDenied + } + if len(token.Resources) > 0 && !resourcesCoverKindWide(token.Resources, kind) { + return ErrAccessDenied + } + } + return nil +} + +// resourcesCoverKindWide reports whether any resource pattern covers the whole kind. +func resourcesCoverKindWide(resources []string, kind string) bool { + for _, res := range resources { + if res == "*" { + return true + } + k, name := splitResource(res) + if k != kind && k != "*" { + continue + } + if name == "" || name == "*" { + return true + } + } + return false +} + +// EnsureUserActive loads user from cache and verifies not disabled (for auth middleware). +func (a *API) EnsureUserActive(userID string) (*userTY.User, error) { + user, err := a.cache.GetUser(userID) + if err != nil { + return nil, ErrUserNotFound + } + if user.Disabled { + return nil, ErrUserDisabled + } + return user, nil +} + +// EnsureServiceTokenActive validates token still valid for requests. +func (a *API) EnsureServiceTokenActive(userID, tokenID string) error { + if tokenID == "" { + return nil + } + token, err := a.cache.GetToken(tokenID) + if err != nil { + return ErrTokenNotFound + } + if token.UserID != userID { + return ErrAccessDenied + } + return validateTokenExpiry(token) +} + +func validateTokenExpiry(token *svcTokenTY.ServiceToken) error { + if token.NeverExpire { + return nil + } + if token.ExpiresOn.IsZero() { + return ErrTokenExpired + } + // ExpiresOn is date-only; treat as end of that calendar day in local time is complex - + // CustomDate Before uses time.Time; compare with start of tomorrow conceptually. + // Keep same semantics as login: ExpiresOn.Before(time.Now()) means expired. + if token.ExpiresOn.Before(time.Now()) { + return ErrTokenExpired + } + return nil +} + +// policiesAllow evaluates all attached policies. +// Explicit Deny matching action+resource always wins over Allow (IAM-style). +// +// For kind-only resources (list entry checks like "field"), a Deny on a *named* +// resource (e.g. field:site-a.secret) does not block the whole list call; +// only Deny on kind-wide patterns (field, field:*, *) blocks list for that kind. +// Named Deny is applied when checking a specific name (get by id / list filters). +func (a *API) policiesAllow(policyIDs []string, action, resource string) bool { + kind, name := splitResource(resource) + if name == "" && kind != "" && kind != "*" { + return a.policiesAllowKind(policyIDs, action, kind, false) + } + return evaluateStatements(a.collectStatements(policyIDs), action, resource) +} + +func (a *API) collectStatements(policyIDs []string) []policyTY.Statement { + out := make([]policyTY.Statement, 0) + for _, id := range policyIDs { + p, err := a.cache.GetPolicy(id) + if err != nil { + continue + } + out = append(out, p.Statements...) + } + return out +} + +// policiesAllowKind evaluates a kind-only resource (a collection, not one object). +// +// requireKindWide=false (collection reachability, e.g. "may I call list / may I POST +// to this endpoint?"): a grant on a single named object is enough, because the +// object-level check happens afterwards on the concrete target. +// +// requireKindWide=true: only "*", "kind" or "kind:*" grants count. Used where no +// concrete target exists to check later (create with a server generated id). +// +// Device-tree: Allow node:gw.* also permits list of source/field (rows filtered by path). +// Kind-wide Deny on an ancestor (node / node:*) blocks list of descendants. +func (a *API) policiesAllowKind(policyIDs []string, action, kind string, requireKindWide bool) bool { + var allowed, denyAll bool + for _, st := range a.collectStatements(policyIDs) { + if !anyActionMatch(st.Actions, action) { + continue + } + effect := normalizeEffect(st.Effect) + for _, res := range st.Resources { + if res == "*" { + if effect == policyTY.EffectDeny { + denyAll = true + } else { + allowed = true + } + continue + } + k, n := splitResource(res) + + // Parent device-tree kinds: Allow cascade / kind-wide Deny cascade + if k != kind && k != "*" && deviceTreeCascadesTo(k, kind) { + if effect == policyTY.EffectDeny && (n == "" || n == "*") { + denyAll = true + } else if effect == policyTY.EffectAllow && (!requireKindWide || n == "" || n == "*") { + // named or kind-wide parent Allow → may list child kind (filters apply) + allowed = true + } + // named parent Deny does not block the list API itself + continue + } + + if k != kind && k != "*" { + continue + } + // kind-wide pattern + if n == "" || n == "*" || k == "*" { + if effect == policyTY.EffectDeny { + denyAll = true + } else { + allowed = true + } + continue + } + // named Allow still permits calling list (filters will scope rows), + // but never satisfies a kind-wide requirement + if effect == policyTY.EffectAllow && !requireKindWide { + allowed = true + } + // named Deny does not block the list API itself + } + } + if denyAll { + return false + } + return allowed +} + +func normalizeEffect(effect string) string { + if effect == "" { + return policyTY.EffectAllow + } + // accept common casings from UI / hand-edited YAML + switch strings.ToLower(strings.TrimSpace(effect)) { + case "deny": + return policyTY.EffectDeny + case "allow": + return policyTY.EffectAllow + default: + return effect + } +} + +func statementMatches(st policyTY.Statement, action, resource string) bool { + if !anyActionMatch(st.Actions, action) { + return false + } + // Device-tree cascade for both effects (Deny still wins in evaluateStatements) + if normalizeEffect(st.Effect) == policyTY.EffectDeny { + return anyResourceMatchDenyCascade(st.Resources, resource) + } + return anyResourceMatchAllowCascade(st.Resources, resource) +} + +func evaluateStatements(statements []policyTY.Statement, action, resource string) bool { + var allowed, denied bool + for _, st := range statements { + if !statementMatches(st, action, resource) { + continue + } + switch normalizeEffect(st.Effect) { + case policyTY.EffectDeny: + denied = true + case policyTY.EffectAllow: + allowed = true + } + } + if denied { + return false + } + return allowed +} + +// statementsAllow is kept for tests: true if any Allow matches and no Deny matches. +func statementsAllow(statements []policyTY.Statement, action, resource string) bool { + return evaluateStatements(statements, action, resource) +} + +func restrictionsAllow(actions, resources []string, action, resource string) bool { + // empty actions in restriction means all actions (still under user ceiling) + if len(actions) > 0 && !anyActionMatch(actions, action) { + return false + } + // token resource limits use the same device-tree Allow cascade as policies + if len(resources) > 0 && !anyResourceMatchAllowCascade(resources, resource) { + return false + } + return true +} + +func anyActionMatch(patterns []string, action string) bool { + for _, p := range patterns { + if MatchAction(p, action) { + return true + } + } + return false +} + +func anyResourceMatchAllowCascade(patterns []string, resource string) bool { + for _, p := range patterns { + if MatchResourceAllowCascade(p, resource) { + return true + } + } + return false +} + +func anyResourceMatchDenyCascade(patterns []string, resource string) bool { + for _, p := range patterns { + if MatchResourceDenyCascade(p, resource) { + return true + } + } + return false +} + +// ResourceNamesForList returns whether list is unrestricted for this kind, and optional name patterns +// from user policies (intersected with token resources). Used for filtered list queries. +// unrestricted=true means no name filter needed. +func (a *API) ResourceNamesForList(subject Subject, kind string) (unrestricted bool, patterns []string, err error) { + if err := a.Allowed(subject, policyTY.ActionList, FormatResource(kind, "")); err != nil { + // also try kind:* + if err2 := a.Allowed(subject, policyTY.ActionList, FormatResource(kind, "*")); err2 != nil { + return false, nil, err + } + } + + user, err := a.cache.GetUser(subject.UserID) + if err != nil { + return false, nil, ErrUserNotFound + } + + // Collect allow/deny name patterns for this kind (list action) + var allowPatterns, denyPatterns []string + hasWildcard := false + denyAll := false + for _, pid := range user.Policies { + p, err := a.cache.GetPolicy(pid) + if err != nil { + continue + } + for _, st := range p.Statements { + if !anyActionMatch(st.Actions, policyTY.ActionList) { + continue + } + effect := normalizeEffect(st.Effect) + for _, res := range st.Resources { + if res == "*" { + if effect == policyTY.EffectDeny { + denyAll = true + } else { + hasWildcard = true + } + continue + } + k, name := splitResource(res) + + // Ancestor kinds (gateway/node/source → field list, etc.) + if k != kind && k != "*" && deviceTreeCascadesTo(k, kind) { + if effect == policyTY.EffectDeny { + if name == "" || name == "*" { + denyAll = true + } else { + // path exclude for children under denied parent + denyPatterns = append(denyPatterns, name) + } + continue + } + // Allow parent path → allow child rows under that path + if name == "" || name == "*" { + hasWildcard = true + } else { + allowPatterns = append(allowPatterns, name) + } + continue + } + + if k != kind && k != "*" { + continue + } + if name == "" || name == "*" { + if effect == policyTY.EffectDeny { + denyAll = true + } else { + hasWildcard = true + } + continue + } + if effect == policyTY.EffectDeny { + denyPatterns = append(denyPatterns, name) + } else { + allowPatterns = append(allowPatterns, name) + } + } + } + } + + if denyAll { + // kind-wide deny → no rows + return false, nil, nil + } + + // Drop allow patterns fully covered by an exact deny (optional tidy) + if len(denyPatterns) > 0 && len(allowPatterns) > 0 { + allowPatterns = subtractPatterns(allowPatterns, denyPatterns) + } + + // Build combined pattern list: + // allow names as-is, deny names as "!name" + // StorageFiltersForList applies allow (OR) AND deny exclusions. + if hasWildcard { + if len(denyPatterns) == 0 { + unrestricted = true + patterns = nil + } else { + // Allow all of kind except deny names + unrestricted = false + patterns = encodeDenyOnlyPatterns(denyPatterns) + } + } else { + unrestricted = false + patterns = append([]string{}, allowPatterns...) + // Critical: Allow node:mysensor.* + Deny node:mysensor.1 must keep both + if len(denyPatterns) > 0 { + patterns = append(patterns, encodeDenyOnlyPatterns(denyPatterns)...) + } + } + + // Intersect with token restrictions (token can only narrow) + if subject.ServiceTokenID != "" { + token, err := a.cache.GetToken(subject.ServiceTokenID) + if err != nil { + return false, nil, ErrTokenNotFound + } + if len(token.Resources) > 0 { + tokenPatterns, tokenWild := tokenNamePatternsForKind(token.Resources, kind) + if !tokenWild { + unrestricted = false + allowPart, denyPart := splitAllowDenyPatterns(patterns) + switch { + case len(tokenPatterns) == 0: + // token names resources, none of which reach this kind -> no rows + allowPart = nil + case hasWildcard && len(allowPart) == 0: + // user policies allow the whole kind: token limits are the effective scope + allowPart = tokenPatterns + default: + allowPart = intersectPatterns(allowPart, tokenPatterns) + } + if len(allowPart) == 0 { + // nothing allowed: a deny-only pattern list would read as + // "everything except ..." downstream, so drop it too + denyPart = nil + } + patterns = append(allowPart, denyPart...) + } + } + } + + return unrestricted, patterns, nil +} + +// tokenNamePatternsForKind collects the name patterns a service token allows for kind. +// Mirrors the policy loop above, including the device-tree parent cascade +// (token resource "gateway:gw" reaches node/source/field/metric rows under gw), +// so list scoping matches what Allowed() permits for a single resource. +func tokenNamePatternsForKind(resources []string, kind string) (patterns []string, wildcard bool) { + for _, res := range resources { + if res == "*" { + return nil, true + } + k, name := splitResource(res) + if k != kind && k != "*" && !deviceTreeCascadesTo(k, kind) { + continue + } + if name == "" || name == "*" { + return nil, true + } + patterns = append(patterns, name) + } + return patterns, false +} + +func splitAllowDenyPatterns(patterns []string) (allow, deny []string) { + for _, p := range patterns { + if len(p) > 0 && p[0] == '!' { + deny = append(deny, p) + } else { + allow = append(allow, p) + } + } + return allow, deny +} + +// encodeDenyOnlyPatterns marks patterns as exclusions for StorageFiltersForList ("all except"). +func encodeDenyOnlyPatterns(deny []string) []string { + out := make([]string, 0, len(deny)) + for _, d := range deny { + out = append(out, "!"+d) + } + return out +} + +func subtractPatterns(allow, deny []string) []string { + if len(deny) == 0 { + return allow + } + out := make([]string, 0, len(allow)) + for _, a := range allow { + denied := false + for _, d := range deny { + if a == d || MatchResource(FormatResource("x", d), FormatResource("x", a)) { + denied = true + break + } + } + if !denied { + out = append(out, a) + } + } + return out +} + +// intersectPatterns keeps only name patterns allowed by both sides +// (a = user policy scope, b = service token scope). When one pattern covers the +// other, the narrower one survives. No overlap means no access, so an empty +// result is a valid answer and must be treated as "no rows" by the caller. +func intersectPatterns(a, b []string) []string { + if len(a) == 0 || len(b) == 0 { + return nil + } + out := make([]string, 0) + seen := make(map[string]struct{}) + keep := func(p string) { + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + for _, tokenPattern := range b { + for _, userPattern := range a { + switch { + case tokenPattern == userPattern, nameCoveredByPattern(userPattern, tokenPattern): + keep(tokenPattern) // token side is equal or narrower + case nameCoveredByPattern(tokenPattern, userPattern): + keep(userPattern) // user side is narrower + } + } + } + return out +} diff --git a/pkg/api/policy/engine_test.go b/pkg/api/policy/engine_test.go new file mode 100644 index 0000000..d5139fb --- /dev/null +++ b/pkg/api/policy/engine_test.go @@ -0,0 +1,312 @@ +package policy + +import ( + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +func TestStatementsAllow(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{policyTY.ActionGet, policyTY.ActionList}, + Resources: []string{"field:plant-room.sensor-01.*"}, + }, + } + if !statementsAllow(sts, "get", "field:plant-room.sensor-01.climate.temp") { + t.Fatal("expected allow get on field") + } + if statementsAllow(sts, "update", "field:plant-room.sensor-01.climate.temp") { + t.Fatal("expected deny update") + } + if statementsAllow(sts, "get", "field:plant-room.other.climate.temp") { + t.Fatal("expected deny other node") + } +} + +func TestStatementsDenyWins(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"*"}, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"settings"}, + }, + } + if !statementsAllow(sts, "get", "gateway:plant-room") { + t.Fatal("expected allow gateway under *") + } + if statementsAllow(sts, "get", "settings") { + t.Fatal("expected deny settings") + } + if statementsAllow(sts, "update", "settings") { + t.Fatal("expected deny settings update") + } +} + +func TestStatementsDenyNamedResource(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{policyTY.ActionGet, policyTY.ActionList}, + Resources: []string{"gateway:*"}, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"gateway:secret-gw"}, + }, + } + if !statementsAllow(sts, "get", "gateway:plant-room") { + t.Fatal("expected allow plant-room") + } + if statementsAllow(sts, "get", "gateway:secret-gw") { + t.Fatal("expected deny secret-gw") + } +} + +// Deny node:mysensor.1 must cascade to fields/sources/metrics under that node. +func TestStatementsDenyNodeCascadesToChildren(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "node:mysensor.*", + "source:mysensor.*", + "field:mysensor.*", + "metric:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + } + if !statementsAllow(sts, "get", "node:mysensor.2") { + t.Fatal("expected allow other node") + } + if statementsAllow(sts, "get", "node:mysensor.1") { + t.Fatal("expected deny node itself") + } + if statementsAllow(sts, "get", "field:mysensor.1.s1.temp") { + t.Fatal("expected deny field under denied node") + } + if statementsAllow(sts, "list", "field:mysensor.1.s1.temp") { + t.Fatal("expected deny list field under denied node") + } + if statementsAllow(sts, "get", "source:mysensor.1.s1") { + t.Fatal("expected deny source under denied node") + } + if statementsAllow(sts, "get", "metric:mysensor.1.s1.temp") { + t.Fatal("expected deny metric under denied node") + } + if !statementsAllow(sts, "get", "field:mysensor.2.s1.temp") { + t.Fatal("expected allow field under other node") + } +} + +func TestStatementsDenyGatewayCascades(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"*"}, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"gateway:secret"}, + }, + } + if statementsAllow(sts, "get", "node:secret.1") { + t.Fatal("expected deny node under denied gateway") + } + if statementsAllow(sts, "get", "field:secret.1.s.f") { + t.Fatal("expected deny field under denied gateway") + } + if !statementsAllow(sts, "get", "field:other.1.s.f") { + t.Fatal("expected allow other gateway fields") + } +} + +// Allow cascades: Allow node:x grants field/source under x (device tree). +func TestStatementsAllowCascadesToChildren(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + } + if !statementsAllow(sts, "get", "node:mysensor.1") { + t.Fatal("expected allow node") + } + if !statementsAllow(sts, "get", "field:mysensor.1.s.f") { + t.Fatal("allow on node must cascade to fields under that node") + } + if !statementsAllow(sts, "get", "source:mysensor.1.s") { + t.Fatal("allow on node must cascade to sources under that node") + } + if statementsAllow(sts, "get", "field:mysensor.2.s.f") { + t.Fatal("must not allow fields under other nodes") + } +} + +// Real user policy shape: allow gateway + node tree, deny one node. +func TestUserPolicyGatewayNodeOnlyWithDeny(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"get", "list"}, + Resources: []string{ + "gateway:mysensor", + "node:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"get", "list"}, + Resources: []string{"node:mysensor.1"}, + }, + } + // siblings allowed via Allow cascade + if !statementsAllow(sts, "get", "field:mysensor.2.s.temp") { + t.Fatal("expected allow field under node 2") + } + if !statementsAllow(sts, "get", "source:mysensor.2.s") { + t.Fatal("expected allow source under node 2") + } + if !statementsAllow(sts, "get", "node:mysensor.2") { + t.Fatal("expected allow node 2") + } + // denied node and children blocked + if statementsAllow(sts, "get", "node:mysensor.1") { + t.Fatal("expected deny node 1") + } + if statementsAllow(sts, "get", "field:mysensor.1.s.temp") { + t.Fatal("expected deny field under node 1") + } + if statementsAllow(sts, "get", "source:mysensor.1.s") { + t.Fatal("expected deny source under node 1") + } + // other gateway not allowed + if statementsAllow(sts, "get", "field:other.1.s.t") { + t.Fatal("must not allow other gateway") + } +} + +func TestRestrictionsAllow(t *testing.T) { + // empty = unrestricted (same as user ceiling) + if !restrictionsAllow(nil, nil, "delete", "gateway:x") { + t.Fatal("empty restrictions should allow") + } + if !restrictionsAllow([]string{"get", "list"}, []string{"field:gw.n.*"}, "get", "field:gw.n.s.f") { + t.Fatal("expected allow") + } + if restrictionsAllow([]string{"get"}, []string{"field:gw.n.*"}, "update", "field:gw.n.s.f") { + t.Fatal("expected deny action") + } +} + +// Full evaluation path: Allow mysensor.* tree + Deny node:mysensor.1 +func TestDenyNodeDoesNotBlockSiblingFields(t *testing.T) { + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "gateway:mysensor", + "node:mysensor.*", + "source:mysensor.*", + "field:mysensor.*", + "metric:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + } + + // siblings must remain allowed + for _, res := range []string{ + "node:mysensor.2", + "source:mysensor.2.s1", + "field:mysensor.2.s1.temp", + "metric:mysensor.2.s1.temp", + } { + if !statementsAllow(sts, "get", res) { + t.Errorf("expected allow get %s", res) + } + if !statementsAllow(sts, "list", res) { + t.Errorf("expected allow list %s", res) + } + } + + // under denied node: blocked + for _, res := range []string{ + "node:mysensor.1", + "source:mysensor.1.s1", + "field:mysensor.1.s1.temp", + "metric:mysensor.1.s1.temp", + } { + if statementsAllow(sts, "get", res) { + t.Errorf("expected deny get %s", res) + } + } +} + +func TestDenyNodeWithReadwriteStyleBareKinds(t *testing.T) { + // like built-in readwrite + deny one node + sts := []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "gateway", "node", "source", "field", "metric", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + } + if !statementsAllow(sts, "get", "field:mysensor.2.s.f") { + t.Fatal("bare kind allow + node deny must still allow other node fields") + } + if statementsAllow(sts, "get", "field:mysensor.1.s.f") { + t.Fatal("must deny fields under denied node") + } + if !statementsAllow(sts, "get", "source:mysensor.2.s") { + t.Fatal("must allow other sources") + } + if statementsAllow(sts, "get", "source:mysensor.1.s") { + t.Fatal("must deny sources under denied node") + } +} + +func TestMatchResourceDenyCascadeDoesNotMatchUnrelated(t *testing.T) { + // kind-wide node deny should cascade to all fields + if !MatchResourceDenyCascade("node", "field:a.b.c.d") { + t.Fatal("kind-wide node deny should cascade") + } + if !MatchResourceDenyCascade("node:*", "field:a.b.c.d") { + t.Fatal("node:* deny should cascade") + } + // named node must not cascade to unrelated + if MatchResourceDenyCascade("node:mysensor.1", "field:other.1.s.f") { + t.Fatal("must not cascade across gateways") + } + if MatchResourceDenyCascade("node:mysensor.1", "field:mysensor.2.s.f") { + t.Fatal("must not cascade to sibling node fields") + } +} diff --git a/pkg/api/policy/list_filter.go b/pkg/api/policy/list_filter.go new file mode 100644 index 0000000..bece07e --- /dev/null +++ b/pkg/api/policy/list_filter.go @@ -0,0 +1,168 @@ +package policy + +import ( + "reflect" + "strings" + + dashboardTY "github.com/mycontroller-org/server/v2/pkg/types/dashboard" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + fwdPayloadTY "github.com/mycontroller-org/server/v2/pkg/types/forward_payload" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + schedulerTY "github.com/mycontroller-org/server/v2/pkg/types/scheduler" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + taskTY "github.com/mycontroller-org/server/v2/pkg/types/task" + vdTY "github.com/mycontroller-org/server/v2/pkg/types/virtual_device" + gatewayTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + handlerTY "github.com/mycontroller-org/server/v2/plugin/handler/types" + vaTY "github.com/mycontroller-org/server/v2/plugin/virtual_assistant/types" +) + +// ItemAllowedForList reports whether one item's business name matches list patterns. +// Patterns may include "!" deny exclusions from ResourceNamesForList. +func ItemAllowedForList(kind string, patterns []string, item interface{}) bool { + name := BusinessName(kind, item) + if name == "" { + return false + } + resource := FormatResource(kind, name) + allowPart, denyPart := splitAllowDenyPatterns(patterns) + + for _, p := range denyPart { + namePat := strings.TrimPrefix(p, "!") + if namePat == "" { + continue + } + if hasResourceKind(namePat) { + _, namePat = splitResource(namePat) + } + if nameCoveredByPattern(namePat, name) { + return false + } + } + + if len(allowPart) == 0 { + // deny-only: allow all except denied + return len(denyPart) > 0 + } + + for _, p := range allowPart { + // patterns are name-only from ResourceNamesForList; also accept full resource strings + pattern := p + if _, n := splitResource(p); n == "" && p != "*" { + // kind-only stored as name by mistake + pattern = FormatResource(kind, p) + } else if !hasResourceKind(p) { + pattern = FormatResource(kind, p) + } + if MatchResource(pattern, resource) { + return true + } + } + return false +} + +func hasResourceKind(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == ':' { + return true + } + } + return false +} + +// BusinessName returns the policy resource name for an entity (not storage UUID when a path exists). +func BusinessName(kind string, item interface{}) string { + if item == nil { + return "" + } + // pointer to struct + v := reflect.ValueOf(item) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return "" + } + item = v.Elem().Interface() + } + + switch kind { + case policyTY.ResourceGateway: + if e, ok := item.(gatewayTY.Config); ok { + return e.ID + } + case policyTY.ResourceNode: + if e, ok := item.(nodeTY.Node); ok { + return joinIDs(e.GatewayID, e.NodeID) + } + case policyTY.ResourceSource: + if e, ok := item.(sourceTY.Source); ok { + return joinIDs(e.GatewayID, e.NodeID, e.SourceID) + } + case policyTY.ResourceField: + if e, ok := item.(fieldTY.Field); ok { + return joinIDs(e.GatewayID, e.NodeID, e.SourceID, e.FieldID) + } + case policyTY.ResourceTask: + if e, ok := item.(taskTY.Config); ok { + return e.ID + } + case policyTY.ResourceSchedule: + if e, ok := item.(schedulerTY.Config); ok { + return e.ID + } + case policyTY.ResourceHandler: + if e, ok := item.(handlerTY.Config); ok { + return e.ID + } + case policyTY.ResourceDashboard: + if e, ok := item.(dashboardTY.Config); ok { + return e.ID + } + case policyTY.ResourceFirmware: + if e, ok := item.(firmwareTY.Firmware); ok { + return e.ID + } + case policyTY.ResourceForwardPayload: + if e, ok := item.(fwdPayloadTY.Config); ok { + return e.ID + } + case policyTY.ResourceDataRepository: + if e, ok := item.(dataRepoTY.Config); ok { + return e.ID + } + case policyTY.ResourceVirtualDevice: + if e, ok := item.(vdTY.VirtualDevice); ok { + return e.ID + } + case policyTY.ResourceVirtualAssistant: + if e, ok := item.(vaTY.Config); ok { + return e.ID + } + } + + // generic: ID field via reflection + rv := reflect.ValueOf(item) + if rv.Kind() == reflect.Struct { + f := rv.FieldByName("ID") + if f.IsValid() && f.Kind() == reflect.String { + return f.String() + } + } + return "" +} + +func joinIDs(parts ...string) string { + out := "" + for i, p := range parts { + if p == "" { + continue + } + if i > 0 && out != "" { + out += "." + } + out += p + } + return out +} diff --git a/pkg/api/policy/list_filter_test.go b/pkg/api/policy/list_filter_test.go new file mode 100644 index 0000000..6e8837a --- /dev/null +++ b/pkg/api/policy/list_filter_test.go @@ -0,0 +1,36 @@ +package policy + +import ( + "testing" + + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + gatewayTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" +) + +func TestBusinessName(t *testing.T) { + if got := BusinessName(policyTY.ResourceGateway, gatewayTY.Config{ID: "home-gw"}); got != "home-gw" { + t.Fatalf("gateway name: %q", got) + } + if got := BusinessName(policyTY.ResourceNode, nodeTY.Node{GatewayID: "home-gw", NodeID: "living-room"}); got != "home-gw.living-room" { + t.Fatalf("node name: %q", got) + } + if got := BusinessName(policyTY.ResourceField, fieldTY.Field{ + GatewayID: "home-gw", NodeID: "living-room", SourceID: "dht", FieldID: "temp", + }); got != "home-gw.living-room.dht.temp" { + t.Fatalf("field name: %q", got) + } +} + +func TestItemAllowedForList(t *testing.T) { + field := fieldTY.Field{GatewayID: "home-gw", NodeID: "living-room", SourceID: "dht", FieldID: "temp"} + patterns := []string{"home-gw.living-room.*"} + if !ItemAllowedForList(policyTY.ResourceField, patterns, field) { + t.Fatal("expected field allowed") + } + other := fieldTY.Field{GatewayID: "home-gw", NodeID: "kitchen", SourceID: "dht", FieldID: "temp"} + if ItemAllowedForList(policyTY.ResourceField, patterns, other) { + t.Fatal("expected kitchen field denied") + } +} diff --git a/pkg/api/policy/list_integration_test.go b/pkg/api/policy/list_integration_test.go new file mode 100644 index 0000000..d9905a4 --- /dev/null +++ b/pkg/api/policy/list_integration_test.go @@ -0,0 +1,303 @@ +package policy + +import ( + "strings" + "testing" + + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + filterUtils "github.com/mycontroller-org/server/v2/pkg/utils/filter_sort" +) + +func mockAPIWithPolicy(t *testing.T, userID string, p policyTY.Policy) *API { + t.Helper() + c := newCache() + u := &userTY.User{ID: userID, Username: "u", Policies: []string{p.ID}} + cp := p + c.PutUser(u) + c.PutPolicy(&cp) + c.setLoaders( + func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, + func(id string) (*policyTY.Policy, error) { return nil, ErrUserNotFound }, + func(id string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func() ([]policyTY.Policy, error) { return nil, nil }, + ) + return &API{cache: c} +} + +func TestResourceNamesForList_DenyNodeExpandsToFieldExclude(t *testing.T) { + p := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "gateway:mysensor", + "node:mysensor.*", + "source:mysensor.*", + "field:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + a := mockAPIWithPolicy(t, "u1", p) + sub := Subject{UserID: "u1"} + + for _, kind := range []string{policyTY.ResourceField, policyTY.ResourceSource, policyTY.ResourceNode} { + unrestricted, patterns, err := a.ResourceNamesForList(sub, kind) + if err != nil { + t.Fatalf("%s: ResourceNamesForList err: %v", kind, err) + } + if unrestricted { + t.Fatalf("%s: expected restricted list", kind) + } + t.Logf("%s patterns: %v", kind, patterns) + allow, deny := splitAllowDenyPatterns(patterns) + if len(deny) == 0 { + t.Fatalf("%s: expected deny patterns, got allow=%v deny=%v", kind, allow, deny) + } + found := false + for _, d := range deny { + if strings.TrimPrefix(d, "!") == "mysensor.1" { + found = true + } + } + if !found { + t.Fatalf("%s: expected !mysensor.1 in deny, got %v", kind, deny) + } + } +} + +func TestStorageFiltersForList_SiblingFieldsRemain(t *testing.T) { + p := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "node:mysensor.*", + "source:mysensor.*", + "field:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + a := mockAPIWithPolicy(t, "u1", p) + sub := Subject{UserID: "u1"} + + unrestricted, filters, err := a.StorageFiltersForList(sub, policyTY.ResourceField) + if err != nil { + t.Fatal(err) + } + if unrestricted { + t.Fatal("expected restricted") + } + t.Logf("filters: %+v", filters) + + entities := []interface{}{ + &fieldTY.Field{ID: "a", GatewayID: "mysensor", NodeID: "1", SourceID: "s", FieldID: "t"}, + &fieldTY.Field{ID: "b", GatewayID: "mysensor", NodeID: "2", SourceID: "s", FieldID: "t"}, + &fieldTY.Field{ID: "c", GatewayID: "mysensor", NodeID: "3", SourceID: "s", FieldID: "t"}, + } + matched := filterUtils.Filter(entities, filters, false) + ids := []string{} + for _, m := range matched { + ids = append(ids, m.(*fieldTY.Field).NodeID) + } + t.Logf("matched nodeIDs: %v", ids) + if len(matched) != 2 { + t.Fatalf("want 2 sibling fields, got %d filters=%+v", len(matched), filters) + } + for _, m := range matched { + if m.(*fieldTY.Field).NodeID == "1" { + t.Fatal("denied node field should not match") + } + } + + _, sFilters, err := a.StorageFiltersForList(sub, policyTY.ResourceSource) + if err != nil { + t.Fatal(err) + } + sources := []interface{}{ + &sourceTY.Source{ID: "a", GatewayID: "mysensor", NodeID: "1", SourceID: "s"}, + &sourceTY.Source{ID: "b", GatewayID: "mysensor", NodeID: "2", SourceID: "s"}, + } + sm := filterUtils.Filter(sources, sFilters, false) + if len(sm) != 1 || sm[0].(*sourceTY.Source).NodeID != "2" { + t.Fatalf("source filter wrong: matched=%d filters=%+v", len(sm), sFilters) + } +} + +func TestStorageFiltersForList_ReadwritePlusDeny(t *testing.T) { + p := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"gateway", "node", "source", "field", "metric"}, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + a := mockAPIWithPolicy(t, "u1", p) + sub := Subject{UserID: "u1"} + + _, filters, err := a.StorageFiltersForList(sub, policyTY.ResourceField) + if err != nil { + t.Fatal(err) + } + t.Logf("deny-only filters: %+v", filters) + + entities := []interface{}{ + &fieldTY.Field{ID: "a", GatewayID: "mysensor", NodeID: "1", SourceID: "s", FieldID: "t"}, + &fieldTY.Field{ID: "b", GatewayID: "mysensor", NodeID: "2", SourceID: "s", FieldID: "t"}, + &fieldTY.Field{ID: "c", GatewayID: "other", NodeID: "9", SourceID: "s", FieldID: "t"}, + } + matched := filterUtils.Filter(entities, filters, false) + if len(matched) != 2 { + t.Fatalf("want 2 fields (all except node1), got %d; filters=%+v", len(matched), filters) + } +} + +func TestAllowed_GetFieldUnderSibling(t *testing.T) { + p := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{ + "field:mysensor.*", + "source:mysensor.*", + "node:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + a := mockAPIWithPolicy(t, "u1", p) + sub := Subject{UserID: "u1"} + + if err := a.Allowed(sub, "get", "field:mysensor.2.s.t"); err != nil { + t.Fatalf("sibling field get: %v", err) + } + if err := a.Allowed(sub, "get", "field:mysensor.1.s.t"); err == nil { + t.Fatal("denied node field should fail") + } + if err := a.Allowed(sub, "list", "field"); err != nil { + t.Fatalf("list field: %v", err) + } + if err := a.Allowed(sub, "get", "source:mysensor.2.s"); err != nil { + t.Fatalf("sibling source get: %v", err) + } + if err := a.Allowed(sub, "get", "source:mysensor.1.s"); err == nil { + t.Fatal("denied node source should fail") + } +} + +// Exact policy from production memory_db (test user): only gateway + node allow. +func TestLiveTestPolicy_NodeAllowCascadesToSourceField(t *testing.T) { + p := policyTY.Policy{ + ID: "test", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"get", "list"}, + Resources: []string{ + "gateway:mysensor", + "node:mysensor.*", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"get", "list"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + a := mockAPIWithPolicy(t, "u1", p) + sub := Subject{UserID: "u1"} + + // list APIs for child kinds must be allowed (then filtered) + for _, kind := range []string{"source", "field", "node", "gateway"} { + if err := a.Allowed(sub, "list", kind); err != nil { + t.Fatalf("list %s: %v", kind, err) + } + } + + // filters: allow under mysensor, exclude node 1 + _, patterns, err := a.ResourceNamesForList(sub, "field") + if err != nil { + t.Fatal(err) + } + t.Logf("field patterns: %v", patterns) + allow, deny := splitAllowDenyPatterns(patterns) + if len(allow) == 0 && !containsPattern(patterns, "mysensor") { + // either explicit allow mysensor.* or hasWildcard with deny only + if len(deny) == 0 { + t.Fatalf("expected allow and/or deny patterns, got %v", patterns) + } + } + foundDeny := false + for _, d := range deny { + if strings.TrimPrefix(d, "!") == "mysensor.1" { + foundDeny = true + } + } + if !foundDeny { + t.Fatalf("expected !mysensor.1 deny, got %v", patterns) + } + + _, filters, err := a.StorageFiltersForList(sub, "field") + if err != nil { + t.Fatal(err) + } + entities := []interface{}{ + &fieldTY.Field{ID: "a", GatewayID: "mysensor", NodeID: "1", SourceID: "s", FieldID: "t"}, + &fieldTY.Field{ID: "b", GatewayID: "mysensor", NodeID: "2", SourceID: "s", FieldID: "t"}, + } + matched := filterUtils.Filter(entities, filters, false) + if len(matched) != 1 || matched[0].(*fieldTY.Field).NodeID != "2" { + t.Fatalf("want only node2 field, got %d filters=%+v", len(matched), filters) + } + + if err := a.Allowed(sub, "get", "field:mysensor.2.s.t"); err != nil { + t.Fatalf("get sibling field: %v", err) + } + if err := a.Allowed(sub, "get", "field:mysensor.1.s.t"); err == nil { + t.Fatal("get denied node field should fail") + } +} + +func containsPattern(patterns []string, sub string) bool { + for _, p := range patterns { + if strings.Contains(p, sub) { + return true + } + } + return false +} diff --git a/pkg/api/policy/mapper.go b/pkg/api/policy/mapper.go new file mode 100644 index 0000000..9fdf53c --- /dev/null +++ b/pkg/api/policy/mapper.go @@ -0,0 +1,256 @@ +package policy + +import ( + "net/http" + "strings" + + "github.com/gorilla/mux" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + settingsTY "github.com/mycontroller-org/server/v2/pkg/types/settings" +) + +// RequestAccess maps an HTTP request to action + resource for authorization. +// name is empty for collection list/create; filled for path id when available. +type RequestAccess struct { + Action string + Resource string // kind or kind:name + Kind string + Name string // path id or empty (may be UUID - fine for coarse check; handlers may re-check) + Skip bool // if true, authorization middleware skips (auth routes already public) +} + +// MapRequest converts HTTP method + path to RBAC action/resource. +func MapRequest(r *http.Request) RequestAccess { + path := r.URL.Path + method := r.Method + + // normalize + path = strings.TrimPrefix(path, "/") + if !strings.HasPrefix(path, "api/") { + return RequestAccess{Skip: true} + } + path = strings.TrimPrefix(path, "api/") + + // special non-restricted already handled by auth middleware + if path == "status" || path == "version" || strings.HasPrefix(path, "user/login") || + strings.HasPrefix(path, "oauth/") || strings.HasPrefix(path, "plugin/gateway") { + return RequestAccess{Skip: true} + } + + // /api/user/profile - own profile (exact path; ids can start with "profile") + if strings.TrimSuffix(path, "/") == "user/profile" { + return RequestAccess{Action: policyTY.ActionGet, Kind: policyTY.ResourceUser, Resource: policyTY.ResourceUser, Name: ""} + } + + // action endpoints + if strings.HasPrefix(path, "action") { + return mapActionRoutes(path, method, r) + } + + // gateway sleeping queue - "clear" mutates state even though it is a GET + if strings.HasPrefix(path, "gateway-sleeping-queue") { + action := policyTY.ActionGet + if strings.Contains(path, "clear") { + action = policyTY.ActionAction + } + return RequestAccess{ + Action: action, + Kind: policyTY.ResourceGateway, + Resource: policyTY.ResourceGateway, + } + } + + // backup / restore + if strings.HasPrefix(path, "backup") || strings.HasPrefix(path, "restore") { + action := policyTY.ActionGet + switch method { + case http.MethodPost, http.MethodGet: + if strings.Contains(path, "run") { + action = policyTY.ActionAction + } else if method == http.MethodDelete { + action = policyTY.ActionDelete + } else if method == http.MethodGet { + action = policyTY.ActionList + } + case http.MethodDelete: + action = policyTY.ActionDelete + } + return RequestAccess{Action: action, Kind: policyTY.ResourceBackup, Resource: policyTY.ResourceBackup} + } + + // settings - sub resources are named so a read-only grant on the ui settings + // does not also expose backup location credentials or allow a jwt secret reset + if strings.HasPrefix(path, "settings") { + return mapSettingsRoutes(path, method) + } + + // metric + if strings.HasPrefix(path, "metric") { + return RequestAccess{Action: policyTY.ActionGet, Kind: policyTY.ResourceMetric, Resource: policyTY.ResourceMetric} + } + + // quickid + if strings.HasPrefix(path, "quickid") { + return RequestAccess{Action: policyTY.ActionGet, Kind: policyTY.ResourceQuickID, Resource: policyTY.ResourceQuickID} + } + + // server status + if strings.HasPrefix(path, "server/status") { + return RequestAccess{Action: policyTY.ActionGet, Kind: policyTY.ResourceStatus, Resource: policyTY.ResourceStatus} + } + + // generic /api/{resource}[/{id}|/enable|/disable|/reload|/create|/update|/upload/...] + parts := strings.Split(path, "/") + if len(parts) == 0 || parts[0] == "" { + return RequestAccess{Skip: true} + } + + kind := normalizeKind(parts[0]) + action := methodToAction(method) + name := "" + + if len(parts) >= 2 { + sub := parts[1] + switch sub { + case "enable": + action = policyTY.ActionEnable + case "disable": + action = policyTY.ActionDisable + case "reload": + action = policyTY.ActionReload + case "create": + action = policyTY.ActionCreate + case "update": + action = policyTY.ActionUpdate + case "upload": + action = policyTY.ActionUpdate + if len(parts) >= 3 { + name = parts[2] + } + default: + // path id + if method == http.MethodGet { + action = policyTY.ActionGet + } + name = sub + // mux vars preferred + if vars := mux.Vars(r); vars != nil { + if id, ok := vars["id"]; ok && id != "" { + name = id + } + } + } + } else { + // collection + switch method { + case http.MethodGet: + action = policyTY.ActionList + case http.MethodPost: + action = policyTY.ActionUpdate // create-or-update style APIs + case http.MethodDelete: + action = policyTY.ActionDelete + } + } + + resource := FormatResource(kind, name) + return RequestAccess{Action: action, Kind: kind, Name: name, Resource: resource} +} + +// mapSettingsRoutes names the settings sub resources using their storage keys, so a +// policy can grant "settings:system_settings" (what the console needs) without +// granting "settings:system_backup_locations" (may hold credentials) or the jwt +// secret reset, which is a GET but invalidates every session. +func mapSettingsRoutes(path, method string) RequestAccess { + settings := func(action, name string) RequestAccess { + return RequestAccess{ + Action: action, + Kind: policyTY.ResourceSettings, + Name: name, + Resource: FormatResource(policyTY.ResourceSettings, name), + } + } + + switch { + case strings.HasPrefix(path, "settings/system/jwtsecret/reset"): + return settings(policyTY.ActionUpdate, settingsTY.KeySystemDynamicSecrets) + case strings.HasPrefix(path, "settings/backuplocations"): + return settings(policyTY.ActionGet, settingsTY.KeySystemBackupLocations) + case strings.HasPrefix(path, "settings/system"): + return settings(policyTY.ActionGet, settingsTY.KeySystemSettings) + } + + action := policyTY.ActionGet + if method == http.MethodPost { + action = policyTY.ActionUpdate + } + // collection level: the body names the settings document (checked separately) + return RequestAccess{Action: action, Kind: policyTY.ResourceSettings, Resource: policyTY.ResourceSettings} +} + +func mapActionRoutes(path, method string, r *http.Request) RequestAccess { + // /api/action/node, /api/action/gateway, /api/action + if strings.HasPrefix(path, "action/node") { + ids := r.URL.Query()["id"] + name := "" + if len(ids) > 0 { + name = ids[0] + } + return RequestAccess{ + Action: policyTY.ActionAction, + Kind: policyTY.ResourceNode, + Name: name, + Resource: FormatResource(policyTY.ResourceNode, name), + } + } + if strings.HasPrefix(path, "action/gateway") { + ids := r.URL.Query()["id"] + name := "" + if len(ids) > 0 { + name = ids[0] + } + return RequestAccess{ + Action: policyTY.ActionAction, + Kind: policyTY.ResourceGateway, + Name: name, + Resource: FormatResource(policyTY.ResourceGateway, name), + } + } + // generic resource action via quick id in query + res := r.URL.Query().Get("resource") + if res != "" { + // quick id like field:gw.n.s.f + if i := strings.Index(res, ":"); i > 0 { + kind := normalizeKind(res[:i]) + return RequestAccess{ + Action: policyTY.ActionAction, + Kind: kind, + Name: res[i+1:], + Resource: FormatResource(kind, res[i+1:]), + } + } + } + return RequestAccess{ + Action: policyTY.ActionAction, + Kind: policyTY.ResourceAction, + Resource: policyTY.ResourceAction, + } +} + +func methodToAction(method string) string { + switch method { + case http.MethodGet: + return policyTY.ActionGet + case http.MethodPost: + return policyTY.ActionUpdate + case http.MethodDelete: + return policyTY.ActionDelete + case http.MethodPut, http.MethodPatch: + return policyTY.ActionUpdate + default: + return policyTY.ActionGet + } +} + +func normalizeKind(segment string) string { + return policyTY.NormalizeKind(segment) +} diff --git a/pkg/api/policy/mapper_test.go b/pkg/api/policy/mapper_test.go new file mode 100644 index 0000000..04d36e1 --- /dev/null +++ b/pkg/api/policy/mapper_test.go @@ -0,0 +1,21 @@ +package policy + +import ( + "net/http" + "net/http/httptest" + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +func TestMapRequestUserProfileExact(t *testing.T) { + profile := MapRequest(httptest.NewRequest(http.MethodGet, "/api/user/profile", nil)) + if profile.Name != "" || profile.Kind != policyTY.ResourceUser || profile.Action != policyTY.ActionGet { + t.Fatalf("profile: %+v", profile) + } + + named := MapRequest(httptest.NewRequest(http.MethodGet, "/api/user/profileX", nil)) + if named.Name != "profileX" || named.Action != policyTY.ActionGet { + t.Fatalf("named profileX should be a user get, got %+v", named) + } +} diff --git a/pkg/api/policy/match.go b/pkg/api/policy/match.go new file mode 100644 index 0000000..22c297c --- /dev/null +++ b/pkg/api/policy/match.go @@ -0,0 +1,198 @@ +package policy + +import ( + "strings" +) + +// MatchAction returns true if pattern matches action. +// "*" matches any action; otherwise an exact match, ignoring case and surrounding +// space so a hand written or UI supplied "Get" behaves like "get". Actions are a +// closed vocabulary, so folding case cannot widen access to a different verb. +// +// Resource *names* stay case sensitive: they are entity ids, compared exactly by +// storage, and folding them could merge two distinct entities. +func MatchAction(pattern, action string) bool { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + action = strings.ToLower(strings.TrimSpace(action)) + if pattern == "" || action == "" { + return false + } + if pattern == "*" { + return true + } + return pattern == action +} + +// MatchResource returns true if pattern matches resource (same kind only). +// Resource format: "kind", "kind:name", or "*". +// For device-tree parent→child cascade (Allow or Deny), use the Cascade helpers. +func MatchResource(pattern, resource string) bool { + if pattern == "" || resource == "" { + return false + } + if pattern == "*" { + return true + } + + pKind, pName := splitResource(pattern) + rKind, rName := splitResource(resource) + + if pKind != "*" && pKind != rKind { + return false + } + + // pattern is kind-only or kind:* + if pName == "" || pName == "*" { + return true + } + + // resource is kind-only (list/create collection check): any pattern for this kind matches + if rName == "" { + return true + } + + return nameMatchesPattern(pName, rName) +} + +// MatchResourceAllowCascade matches an Allow pattern against a resource. +// Device-tree parent allows grant children under the same path: +// +// Allow node:gw.n → field:gw.n.s.f, source:gw.n.s, metric:gw.n… +// Allow gateway:gw → node/source/field/metric under gw +// Allow node:gw.* → all sources/fields under that gateway +// +// Named parent Allow also permits the child list API (filters scope rows). +// Kind-wide Allow on an ancestor (node / node:*) grants all of the child kind. +func MatchResourceAllowCascade(pattern, resource string) bool { + if MatchResource(pattern, resource) { + return true + } + return matchDeviceTreeCascade(pattern, resource, true) +} + +// MatchResourceDenyCascade matches a Deny pattern against a resource. +// Device-tree parent denies block children under the same path (same hierarchy as Allow). +// Named Deny does not block the kind-only collection resource (list entry check); +// list rows are filtered separately. +func MatchResourceDenyCascade(pattern, resource string) bool { + if pattern == "" || resource == "" { + return false + } + if pattern == "*" { + return true + } + + pKind, pName := splitResource(pattern) + rKind, rName := splitResource(resource) + if pKind == "" || rKind == "" { + return false + } + + // Same kind (or pattern kind *) + if pKind == "*" || pKind == rKind { + if pName == "" || pName == "*" || pKind == "*" { + return true + } + if rName == "" { + return false // named deny does not block list API + } + return nameCoveredByPattern(pName, rName) + } + + return matchDeviceTreeCascade(pattern, resource, false) +} + +// matchDeviceTreeCascade: parent kind pattern covers child kind resource by path. +// allowCollection: when true, named parent patterns match kind-only child resources (list). +func matchDeviceTreeCascade(pattern, resource string, allowCollection bool) bool { + pKind, pName := splitResource(pattern) + rKind, rName := splitResource(resource) + if !deviceTreeCascadesTo(pKind, rKind) { + return false + } + if pName == "" || pName == "*" { + // kind-wide parent → all children of that kind + return true + } + if rName == "" { + return allowCollection + } + return nameCoveredByPattern(pName, rName) +} + +// deviceTreeCascadesTo reports whether patternKind is a device-tree ancestor of resourceKind. +// Same kind is handled by the caller; this is parent→child only. +// Hierarchy: gateway → node → source → field/metric. +func deviceTreeCascadesTo(patternKind, resourceKind string) bool { + if patternKind == "" || resourceKind == "" || patternKind == resourceKind { + return false + } + ancestors := map[string][]string{ + "node": {"gateway"}, + "source": {"gateway", "node"}, + "field": {"gateway", "node", "source"}, + "metric": {"gateway", "node", "source", "field"}, + } + for _, a := range ancestors[resourceKind] { + if a == patternKind { + return true + } + } + return false +} + +// nameCoveredByPattern: pattern name matches resource name, or is a strict +// hierarchical parent (mysensor.1 covers mysensor.1.s.f). +// Segment-safe: mysensor.1 does not cover mysensor.10. +func nameCoveredByPattern(patternName, resourceName string) bool { + if nameMatchesPattern(patternName, resourceName) { + return true + } + // exact parent path (no wildcards): "a.b" covers "a.b.c.d" + if strings.Contains(patternName, "*") { + return false + } + if resourceName == patternName { + return true + } + return strings.HasPrefix(resourceName, patternName+".") +} + +func nameMatchesPattern(pName, rName string) bool { + if pName == rName { + return true + } + // trailing wildcard: "home-gw.living-room.*" + if strings.HasSuffix(pName, ".*") { + prefix := strings.TrimSuffix(pName, ".*") + if rName == prefix { + return true + } + return strings.HasPrefix(rName, prefix+".") + } + if strings.HasSuffix(pName, "*") { + prefix := strings.TrimSuffix(pName, "*") + return strings.HasPrefix(rName, prefix) + } + return false +} + +// FormatResource builds "kind" or "kind:name". +func FormatResource(kind, name string) string { + if kind == "" { + return "" + } + if name == "" || name == "*" { + return kind + } + return kind + ":" + name +} + +func splitResource(resource string) (kind, name string) { + parts := strings.SplitN(resource, ":", 2) + kind = strings.ToLower(strings.TrimSpace(parts[0])) + if len(parts) == 2 { + name = strings.TrimSpace(parts[1]) + } + return kind, name +} diff --git a/pkg/api/policy/match_test.go b/pkg/api/policy/match_test.go new file mode 100644 index 0000000..b252fe0 --- /dev/null +++ b/pkg/api/policy/match_test.go @@ -0,0 +1,89 @@ +package policy + +import "testing" + +func TestMatchAction(t *testing.T) { + cases := []struct { + pattern, action string + want bool + }{ + {"*", "get", true}, + {"get", "get", true}, + {"get", "list", false}, + {"", "get", false}, + } + for _, c := range cases { + if got := MatchAction(c.pattern, c.action); got != c.want { + t.Errorf("MatchAction(%q,%q)=%v want %v", c.pattern, c.action, got, c.want) + } + } +} + +func TestMatchResource(t *testing.T) { + cases := []struct { + pattern, resource string + want bool + }{ + {"*", "field:a.b.c.d", true}, + {"field", "field:a.b.c.d", true}, + {"field:*", "field:a.b.c.d", true}, + {"field:a.b.c.d", "field:a.b.c.d", true}, + {"field:a.b.c.d", "field:a.b.c.other", false}, + {"field:home-gw.living-room.*", "field:home-gw.living-room.dht.temp", true}, + {"field:home-gw.living-room.*", "field:home-gw.living-room", true}, + {"field:home-gw.living-room.*", "field:home-gw.kitchen.dht.temp", false}, + {"node:home-gw.*", "node:home-gw.living-room", true}, + {"gateway:home-gw", "gateway:home-gw", true}, + {"gateway:home-gw", "node:home-gw.x", false}, + {"task:night-mode", "task:night-mode", true}, + // collection (list) checks use kind without name + {"field:home-gw.living-room.*", "field", true}, + {"gateway:home-gw", "gateway", true}, + {"node:x", "field", false}, + } + for _, c := range cases { + if got := MatchResource(c.pattern, c.resource); got != c.want { + t.Errorf("MatchResource(%q,%q)=%v want %v", c.pattern, c.resource, got, c.want) + } + } +} + +func TestMatchResourceDenyCascade(t *testing.T) { + cases := []struct { + pattern, resource string + want bool + }{ + // same-kind still works + {"node:mysensor.1", "node:mysensor.1", true}, + {"node:mysensor.1", "node:mysensor.2", false}, + // node deny → field/source/metric under path + {"node:mysensor.1", "field:mysensor.1.s1.temp", true}, + {"node:mysensor.1", "source:mysensor.1.s1", true}, + {"node:mysensor.1", "metric:mysensor.1.s1.temp", true}, + {"node:mysensor.1", "field:mysensor.2.s1.temp", false}, + // gateway deny → children + {"gateway:mysensor", "node:mysensor.1", true}, + {"gateway:mysensor", "field:mysensor.1.s.f", true}, + {"gateway:mysensor", "field:other.1.s.f", false}, + // source deny → field + {"source:mysensor.1.s1", "field:mysensor.1.s1.temp", true}, + {"source:mysensor.1.s1", "field:mysensor.1.other.temp", false}, + // Allow-style MatchResource must stay false for cross-kind + // (cascade is Deny-only API) + {"node:mysensor.1", "task:x", false}, + // kind-wide parent deny + {"gateway:*", "field:a.b.c.d", true}, + {"node", "field:a.b.c.d", true}, + // named parent does not block collection resource + {"node:mysensor.1", "field", false}, + } + for _, c := range cases { + if got := MatchResourceDenyCascade(c.pattern, c.resource); got != c.want { + t.Errorf("MatchResourceDenyCascade(%q,%q)=%v want %v", c.pattern, c.resource, got, c.want) + } + } + // Ensure plain MatchResource still does not cascade + if MatchResource("node:mysensor.1", "field:mysensor.1.s.f") { + t.Fatal("MatchResource must not cascade across kinds") + } +} diff --git a/pkg/api/policy/metric_auth.go b/pkg/api/policy/metric_auth.go new file mode 100644 index 0000000..fd5aa23 --- /dev/null +++ b/pkg/api/policy/metric_auth.go @@ -0,0 +1,175 @@ +package policy + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + types "github.com/mycontroller-org/server/v2/pkg/types" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + quickIdUL "github.com/mycontroller-org/server/v2/pkg/utils/quick_id" + mtsTY "github.com/mycontroller-org/server/v2/plugin/database/metric/types" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +// AuthorizeMetricRequest enforces metric access for GET (quick_id) or POST (body queries). +// Resource patterns use kind "metric" with the same hierarchical names as fields: +// +// metric:* all metrics +// metric:home-gw.* all fields under gateway +// metric:home-gw.living-room.* all under node +// metric:home-gw.n.s.* all under source +// metric:home-gw.n.s.temperature one field +// +// Built-in policies that list bare "metric" still allow all (kind-only match). +func (a *API) AuthorizeMetricRequest(subject Subject, r *http.Request) error { + switch r.Method { + case http.MethodGet: + return a.authorizeMetricGET(subject, r) + case http.MethodPost: + return a.authorizeMetricPOST(subject, r) + default: + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } +} + +func (a *API) authorizeMetricGET(subject Subject, r *http.Request) error { + quickID := r.URL.Query().Get(QuickIDParam) + if quickID == "" { + // alternate casing used in some clients + quickID = r.URL.Query().Get("quickId") + } + if quickID == "" { + // no target: require unrestricted metric API + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } + path, err := a.fieldPathFromQuickID(quickID) + if err != nil { + return err + } + return a.AllowedMetricPath(subject, path) +} + +func (a *API) authorizeMetricPOST(subject Subject, r *http.Request) error { + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + // restore body for the handler + r.Body = io.NopCloser(strings.NewReader(string(body))) + + if len(body) == 0 { + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } + + queryConfig := &mtsTY.QueryConfig{} + if err := json.Unmarshal(body, queryConfig); err != nil { + // let handler report bad JSON; only require generic metric access + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } + + if len(queryConfig.Individual) == 0 { + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } + + for i := range queryConfig.Individual { + q := queryConfig.Individual[i] + path, err := a.fieldPathFromMetricQuery(&q) + if err != nil { + return err + } + if path == "" { + // no identifiable field: require full metric access + if err := a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric); err != nil { + return fmt.Errorf("metric query %d: %w", i, err) + } + continue + } + if err := a.AllowedMetricPath(subject, path); err != nil { + return fmt.Errorf("metric query %d (%s): %w", i, path, err) + } + } + return nil +} + +// AllowedMetricPath checks get on metric: (hierarchical match). +func (a *API) AllowedMetricPath(subject Subject, fieldPath string) error { + if fieldPath == "" { + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceMetric) + } + return a.Allowed(subject, policyTY.ActionGet, FormatResource(policyTY.ResourceMetric, fieldPath)) +} + +// QuickIDParam matches routes/metric.go +const QuickIDParam = "quick_id" + +func (a *API) fieldPathFromQuickID(quickID string) (string, error) { + rt, kvMap, err := quickIdUL.EntityKeyValueMap(quickID) + if err != nil { + return "", err + } + switch rt { + case quickIdUL.QuickIdField: + return joinIDs( + kvMap[types.KeyGatewayID], + kvMap[types.KeyNodeID], + kvMap[types.KeySourceID], + kvMap[types.KeyFieldID], + ), nil + case quickIdUL.QuickIdSource: + return joinIDs(kvMap[types.KeyGatewayID], kvMap[types.KeyNodeID], kvMap[types.KeySourceID]), nil + case quickIdUL.QuickIdNode: + return joinIDs(kvMap[types.KeyGatewayID], kvMap[types.KeyNodeID]), nil + case quickIdUL.QuickIdGateway: + return kvMap[types.KeyGatewayID], nil + default: + return "", fmt.Errorf("metric resource type not supported: %s", rt) + } +} + +func (a *API) fieldPathFromMetricQuery(q *mtsTY.Query) (string, error) { + if q == nil || q.Tags == nil { + return "", nil + } + // Prefer explicit hierarchy tags if present + gw := firstTag(q.Tags, types.KeyGatewayID, "gatewayId", "GatewayID") + node := firstTag(q.Tags, types.KeyNodeID, "nodeId", "NodeID") + src := firstTag(q.Tags, types.KeySourceID, "sourceId", "SourceID") + field := firstTag(q.Tags, types.KeyFieldID, "fieldId", "FieldID") + if gw != "" && node != "" && src != "" && field != "" { + return joinIDs(gw, node, src, field), nil + } + if gw != "" && node != "" && src != "" { + return joinIDs(gw, node, src), nil + } + if gw != "" && node != "" { + return joinIDs(gw, node), nil + } + if gw != "" && field == "" && node == "" { + return gw, nil + } + + // UI usually sends tags.id = field storage UUID + id := firstTag(q.Tags, types.KeyID, "id", "ID") + if id == "" { + return "", nil + } + var e fieldTY.Field + err := a.storage.FindOne(types.EntityField, &e, []storageTY.Filter{{Key: types.KeyID, Value: id}}) + if err != nil { + return "", fmt.Errorf("metric field id %s: %w", id, err) + } + return BusinessName(policyTY.ResourceField, e), nil +} + +func firstTag(tags map[string]string, keys ...string) string { + for _, k := range keys { + if v, ok := tags[k]; ok && v != "" { + return v + } + } + return "" +} diff --git a/pkg/api/policy/metric_auth_test.go b/pkg/api/policy/metric_auth_test.go new file mode 100644 index 0000000..b0d3dfc --- /dev/null +++ b/pkg/api/policy/metric_auth_test.go @@ -0,0 +1,30 @@ +package policy + +import ( + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +func TestMetricPathMatching(t *testing.T) { + // hierarchical metric patterns reuse MatchResource + cases := []struct { + pattern, resource string + want bool + }{ + {"metric:mysensor.*", "metric:mysensor.1.s.temp", true}, + {"metric:mysensor.1.*", "metric:mysensor.1.s.temp", true}, + {"metric:mysensor.1.s.*", "metric:mysensor.1.s.temp", true}, + {"metric:mysensor.1.s.temp", "metric:mysensor.1.s.temp", true}, + {"metric:mysensor.*", "metric:other.1.s.temp", false}, + {"metric", "metric:mysensor.1.s.temp", true}, // kind-only = all metrics + {"metric:*", "metric:mysensor.1.s.temp", true}, + {"*", "metric:mysensor.1.s.temp", true}, + } + for _, c := range cases { + if got := MatchResource(c.pattern, c.resource); got != c.want { + t.Errorf("MatchResource(%q,%q)=%v want %v", c.pattern, c.resource, got, c.want) + } + } + _ = policyTY.ResourceMetric +} diff --git a/pkg/api/policy/query_filters.go b/pkg/api/policy/query_filters.go new file mode 100644 index 0000000..ee93eba --- /dev/null +++ b/pkg/api/policy/query_filters.go @@ -0,0 +1,268 @@ +package policy + +import ( + "regexp" + "strings" + + types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +// StorageFiltersForList builds storage filters that enforce list resource patterns +// at query time (AND-ed with client filters). unrestricted means no extra filters. +// Patterns prefixed with "!" are Deny exclusions; others are Allow includes. +// Final query: (allow1 OR allow2 OR ...) AND (not deny1) AND (not deny2) ... +func (a *API) StorageFiltersForList(subject Subject, kind string) (unrestricted bool, filters []storageTY.Filter, err error) { + unrestricted, patterns, err := a.ResourceNamesForList(subject, kind) + if err != nil { + return false, nil, err + } + if unrestricted { + return true, nil, nil + } + if len(patterns) == 0 { + return false, matchNothingFilters(), nil + } + + allowPart, denyPart := splitAllowDenyPatterns(patterns) + + var out []storageTY.Filter + + // Positive allow scope (OR of pattern groups) + if len(allowPart) > 0 { + groups := make([][]storageTY.Filter, 0, len(allowPart)) + for _, p := range allowPart { + name := p + if hasResourceKind(p) { + _, name = splitResource(p) + } + g := patternToFilterGroup(kind, name) + if len(g) > 0 { + groups = append(groups, g) + } + } + if len(groups) == 0 { + return false, matchNothingFilters(), nil + } + if len(groups) == 1 { + out = append(out, groups[0]...) + } else { + out = append(out, storageTY.Filter{ + Operator: storageTY.OperatorOr, + Value: groups, + }) + } + } else if len(denyPart) == 0 { + return false, matchNothingFilters(), nil + } + + // Deny exclusions (AND) + if len(denyPart) > 0 { + out = append(out, denyOnlyFilters(kind, denyPart)...) + } + + if len(out) == 0 { + return false, matchNothingFilters(), nil + } + return false, out, nil +} + +func matchNothingFilters() []storageTY.Filter { + return []storageTY.Filter{{ + Key: types.KeyID, + Operator: storageTY.OperatorIn, + Value: []string{}, + }} +} + +// denyOnlyFilters builds exclusion filters for allow-all-except-deny patterns. +// Each "!" name becomes a top-level AND filter meaning NOT that resource. +// Hierarchical paths use OperatorNor on the positive path match +// (NOT (GatewayID=gw AND NodeID=n …)). +func denyOnlyFilters(kind string, patterns []string) []storageTY.Filter { + var filters []storageTY.Filter + exactIDs := make([]string, 0) + + for _, p := range patterns { + name := strings.TrimPrefix(p, "!") + if name == "" { + continue + } + if isIDKeyedKind(kind) && !strings.Contains(name, "*") { + exactIDs = append(exactIDs, name) + continue + } + excl := hierarchicalExcludeFilter(kind, name) + if excl != nil { + filters = append(filters, *excl) + } + } + + if len(exactIDs) > 0 { + filters = append(filters, storageTY.Filter{ + Key: types.KeyID, + Operator: storageTY.OperatorNotIn, + Value: exactIDs, + }) + } + if len(filters) == 0 { + // Could not encode denies; fail closed for list scope + return matchNothingFilters() + } + return filters +} + +// hierarchicalExcludeFilter returns a filter matching entities NOT under namePattern. +// Example: kind=field, name=mysensor.1 → NOR[GatewayID=mysensor, NodeID=1] +// Example: kind=node, name=mysensor.* → NOR[GatewayID=mysensor] +// +// Uses OperatorNor (NOT of the positive path) so it AND-s cleanly with Allow +// filters and does not rely on De Morgan OR of NotEqual (which collides with +// other $or groups in MongoDB). +func hierarchicalExcludeFilter(kind, namePattern string) *storageTY.Filter { + switch kind { + case policyTY.ResourceNode, policyTY.ResourceSource, policyTY.ResourceField: + positive := patternToFilterGroup(kind, namePattern) + if len(positive) == 0 { + return nil + } + return &storageTY.Filter{ + Operator: storageTY.OperatorNor, + Value: positive, + } + default: + if !strings.Contains(namePattern, "*") { + return &storageTY.Filter{ + Key: types.KeyID, + Operator: storageTY.OperatorNotEqual, + Value: namePattern, + } + } + // wildcards on id-keyed kinds: regex positive → not easily NOR-able; fail open nil + // (caller fails closed if no filters) + return nil + } +} + +func isIDKeyedKind(kind string) bool { + switch kind { + case policyTY.ResourceGateway, policyTY.ResourceTask, policyTY.ResourceSchedule, + policyTY.ResourceHandler, policyTY.ResourceDashboard, policyTY.ResourceFirmware, + policyTY.ResourceForwardPayload, policyTY.ResourceDataRepository, + policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, + policyTY.ResourceServiceToken, policyTY.ResourceUser, policyTY.ResourcePolicy: + return true + default: + return false + } +} + +// patternToFilterGroup converts a resource name pattern into AND filters on entity fields. +// Supports trailing ".*" / "*" wildcards on hierarchical names. +func patternToFilterGroup(kind, namePattern string) []storageTY.Filter { + namePattern = strings.TrimSpace(namePattern) + if namePattern == "" || namePattern == "*" { + return nil + } + + // ID-keyed resources (gateway, task, schedule, ...) + switch kind { + case policyTY.ResourceGateway, + policyTY.ResourceTask, + policyTY.ResourceSchedule, + policyTY.ResourceHandler, + policyTY.ResourceDashboard, + policyTY.ResourceFirmware, + policyTY.ResourceForwardPayload, + policyTY.ResourceDataRepository, + policyTY.ResourceVirtualDevice, + policyTY.ResourceVirtualAssistant, + policyTY.ResourceServiceToken, + policyTY.ResourceUser, + policyTY.ResourcePolicy: + return idPatternFilters(types.KeyID, namePattern) + } + + // hierarchical device-tree resources + parts := splitNamePattern(namePattern) + switch kind { + case policyTY.ResourceNode: + return hierarchicalFilters(parts, []string{types.KeyGatewayID, types.KeyNodeID}) + case policyTY.ResourceSource: + return hierarchicalFilters(parts, []string{types.KeyGatewayID, types.KeyNodeID, types.KeySourceID}) + case policyTY.ResourceField: + return hierarchicalFilters(parts, []string{types.KeyGatewayID, types.KeyNodeID, types.KeySourceID, types.KeyFieldID}) + default: + return idPatternFilters(types.KeyID, namePattern) + } +} + +func idPatternFilters(key, namePattern string) []storageTY.Filter { + if strings.HasSuffix(namePattern, ".*") { + prefix := strings.TrimSuffix(namePattern, ".*") + return []storageTY.Filter{{ + Key: key, + Operator: storageTY.OperatorRegexCaseSensitive, + Value: "^" + regexp.QuoteMeta(prefix) + `($|\.)`, + }} + } + if strings.HasSuffix(namePattern, "*") && !strings.HasSuffix(namePattern, ".*") { + prefix := strings.TrimSuffix(namePattern, "*") + return []storageTY.Filter{{ + Key: key, + Operator: storageTY.OperatorRegexCaseSensitive, + Value: "^" + regexp.QuoteMeta(prefix), + }} + } + return []storageTY.Filter{{ + Key: key, + Operator: storageTY.OperatorEqual, + Value: namePattern, + }} +} + +// hierarchicalFilters maps name segments onto entity keys. +// A segment "*" or trailing ".*" stops further constraints (prefix scope). +func hierarchicalFilters(parts []string, keys []string) []storageTY.Filter { + filters := make([]storageTY.Filter, 0, len(keys)) + for i, key := range keys { + if i >= len(parts) { + break + } + seg := parts[i] + if seg == "*" { + break + } + // last segment may be "foo*" style + if strings.HasSuffix(seg, "*") { + prefix := strings.TrimSuffix(seg, "*") + filters = append(filters, storageTY.Filter{ + Key: key, + Operator: storageTY.OperatorRegexCaseSensitive, + Value: "^" + regexp.QuoteMeta(prefix), + }) + break + } + filters = append(filters, storageTY.Filter{ + Key: key, + Operator: storageTY.OperatorEqual, + Value: seg, + }) + } + return filters +} + +func splitNamePattern(name string) []string { + // "home-gw.living-room.*" → ["home-gw", "living-room", "*"] + name = strings.TrimSpace(name) + if strings.HasSuffix(name, ".*") { + base := strings.TrimSuffix(name, ".*") + if base == "" { + return []string{"*"} + } + parts := strings.Split(base, ".") + return append(parts, "*") + } + return strings.Split(name, ".") +} diff --git a/pkg/api/policy/query_filters_test.go b/pkg/api/policy/query_filters_test.go new file mode 100644 index 0000000..a3ba8a4 --- /dev/null +++ b/pkg/api/policy/query_filters_test.go @@ -0,0 +1,156 @@ +package policy + +import ( + "testing" + + types "github.com/mycontroller-org/server/v2/pkg/types" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +func TestPatternToFilterGroupFieldExact(t *testing.T) { + g := patternToFilterGroup(policyTY.ResourceField, "home-gw.living-room.dht.temp") + if len(g) != 4 { + t.Fatalf("want 4 filters, got %d: %+v", len(g), g) + } + want := map[string]string{ + types.KeyGatewayID: "home-gw", + types.KeyNodeID: "living-room", + types.KeySourceID: "dht", + types.KeyFieldID: "temp", + } + for _, f := range g { + if f.Operator != storageTY.OperatorEqual { + t.Fatalf("op: %s", f.Operator) + } + if want[f.Key] != f.Value { + t.Fatalf("key %s: got %v want %v", f.Key, f.Value, want[f.Key]) + } + } +} + +func TestPatternToFilterGroupFieldPrefix(t *testing.T) { + g := patternToFilterGroup(policyTY.ResourceField, "home-gw.living-room.*") + if len(g) != 2 { + t.Fatalf("want GatewayID+NodeID only, got %+v", g) + } + if g[0].Key != types.KeyGatewayID || g[0].Value != "home-gw" { + t.Fatalf("gateway: %+v", g[0]) + } + if g[1].Key != types.KeyNodeID || g[1].Value != "living-room" { + t.Fatalf("node: %+v", g[1]) + } +} + +func TestPatternToFilterGroupGateway(t *testing.T) { + g := patternToFilterGroup(policyTY.ResourceGateway, "home-gw") + if len(g) != 1 || g[0].Key != types.KeyID || g[0].Value != "home-gw" { + t.Fatalf("got %+v", g) + } +} + +func TestPatternToFilterGroupGatewayPrefixIsSegmentSafe(t *testing.T) { + g := patternToFilterGroup(policyTY.ResourceGateway, "home.*") + if len(g) != 1 || g[0].Key != types.KeyID { + t.Fatalf("got %+v", g) + } + if g[0].Operator != storageTY.OperatorRegexCaseSensitive { + t.Fatalf("op: %s", g[0].Operator) + } + if g[0].Value != `^home($|\.)` { + t.Fatalf("regex: %v", g[0].Value) + } +} + +func TestHierarchicalExcludeFilterNodeExact(t *testing.T) { + // Deny node:mysensor.1 → NOR[GatewayID=mysensor, NodeID=1] + f := hierarchicalExcludeFilter(policyTY.ResourceNode, "mysensor.1") + if f == nil { + t.Fatal("expected filter") + } + if f.Operator != storageTY.OperatorNor { + t.Fatalf("want Nor, got %s %+v", f.Operator, f) + } + pos, ok := f.Value.([]storageTY.Filter) + if !ok || len(pos) != 2 { + t.Fatalf("want positive AND group of 2, got %#v", f.Value) + } + if pos[0].Key != types.KeyGatewayID || pos[0].Operator != storageTY.OperatorEqual || pos[0].Value != "mysensor" { + t.Fatalf("gateway: %+v", pos[0]) + } + if pos[1].Key != types.KeyNodeID || pos[1].Operator != storageTY.OperatorEqual || pos[1].Value != "1" { + t.Fatalf("node: %+v", pos[1]) + } +} + +func TestHierarchicalExcludeFilterGatewayPrefix(t *testing.T) { + f := hierarchicalExcludeFilter(policyTY.ResourceNode, "mysensor.*") + if f == nil { + t.Fatal("expected filter") + } + if f.Operator != storageTY.OperatorNor { + t.Fatalf("want Nor, got %+v", f) + } + pos, ok := f.Value.([]storageTY.Filter) + if !ok || len(pos) != 1 { + t.Fatalf("want single gateway equal, got %#v", f.Value) + } + if pos[0].Key != types.KeyGatewayID || pos[0].Value != "mysensor" { + t.Fatalf("got %+v", pos[0]) + } +} + +func TestAllowWildcardPlusDenyCombines(t *testing.T) { + // Simulate StorageFiltersForList pattern list: allow mysensor.* + deny mysensor.1 + patterns := []string{"mysensor.*", "!mysensor.1"} + allowPart, denyPart := splitAllowDenyPatterns(patterns) + if len(allowPart) != 1 || allowPart[0] != "mysensor.*" { + t.Fatalf("allow: %v", allowPart) + } + if len(denyPart) != 1 || denyPart[0] != "!mysensor.1" { + t.Fatalf("deny: %v", denyPart) + } + allowG := patternToFilterGroup(policyTY.ResourceNode, "mysensor.*") + if len(allowG) != 1 || allowG[0].Key != types.KeyGatewayID || allowG[0].Value != "mysensor" { + t.Fatalf("allow filter: %+v", allowG) + } + excl := hierarchicalExcludeFilter(policyTY.ResourceNode, "mysensor.1") + if excl == nil || excl.Operator != storageTY.OperatorNor { + t.Fatalf("exclude: %+v", excl) + } +} + +// Deny node:mysensor.1 when listing fields → NOR path on field keys +func TestHierarchicalExcludeFilterFieldUnderNode(t *testing.T) { + f := hierarchicalExcludeFilter(policyTY.ResourceField, "mysensor.1") + if f == nil { + t.Fatal("expected filter") + } + if f.Operator != storageTY.OperatorNor { + t.Fatalf("want Nor, got %+v", f) + } + pos, ok := f.Value.([]storageTY.Filter) + if !ok || len(pos) != 2 { + t.Fatalf("want GatewayID+NodeID equal group, got %#v", f.Value) + } + if pos[0].Key != types.KeyGatewayID || pos[0].Value != "mysensor" { + t.Fatalf("gateway: %+v", pos[0]) + } + if pos[1].Key != types.KeyNodeID || pos[1].Value != "1" { + t.Fatalf("node: %+v", pos[1]) + } +} + +func TestItemAllowedForListDenyExclusion(t *testing.T) { + // allow mysensor.* fields, deny under node 1 + patterns := []string{"mysensor.*", "!mysensor.1"} + okField := fieldTY.Field{GatewayID: "mysensor", NodeID: "2", SourceID: "s", FieldID: "t"} + badField := fieldTY.Field{GatewayID: "mysensor", NodeID: "1", SourceID: "s", FieldID: "t"} + if !ItemAllowedForList(policyTY.ResourceField, patterns, okField) { + t.Fatal("expected field under node 2 allowed") + } + if ItemAllowedForList(policyTY.ResourceField, patterns, badField) { + t.Fatal("expected field under node 1 denied") + } +} diff --git a/pkg/api/policy/quickid_auth.go b/pkg/api/policy/quickid_auth.go new file mode 100644 index 0000000..85367f5 --- /dev/null +++ b/pkg/api/policy/quickid_auth.go @@ -0,0 +1,88 @@ +package policy + +import ( + "fmt" + "net/http" + "strings" + + types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + quickIdUL "github.com/mycontroller-org/server/v2/pkg/utils/quick_id" +) + +// AuthorizeQuickIDRequest enforces access for GET /api/quickid?id=... +// Bare "quickid" in a policy only opens the API; each requested id is checked +// against the underlying resource (field:/node:/…), including device-tree cascade. +// Any denied id fails the whole request (403). +func (a *API) AuthorizeQuickIDRequest(subject Subject, r *http.Request) error { + ids := r.URL.Query()["id"] + if len(ids) == 0 { + // no targets: require generic quickid access + return a.Allowed(subject, policyTY.ActionGet, policyTY.ResourceQuickID) + } + + // Optional coarse gate: if the principal has neither quickid nor any device access, + // fail fast. Prefer per-id checks so "quickid" alone cannot leak denied fields. + for _, quickID := range ids { + quickID = strings.TrimSpace(quickID) + if quickID == "" { + continue + } + res, err := ResourceFromQuickID(quickID) + if err != nil { + return err + } + if err := a.Allowed(subject, policyTY.ActionGet, res); err != nil { + return fmt.Errorf("quickid %s: %w", quickID, err) + } + } + return nil +} + +// ResourceFromQuickID maps a quick id (e.g. field:gw.n.s.f) to a policy resource string. +func ResourceFromQuickID(quickID string) (string, error) { + rt, kv, err := quickIdUL.EntityKeyValueMap(quickID) + if err != nil { + return "", err + } + + switch strings.ToLower(rt) { + case quickIdUL.QuickIdGateway: + return FormatResource(policyTY.ResourceGateway, kv[types.KeyGatewayID]), nil + case quickIdUL.QuickIdNode: + return FormatResource(policyTY.ResourceNode, joinIDs(kv[types.KeyGatewayID], kv[types.KeyNodeID])), nil + case quickIdUL.QuickIdSource: + return FormatResource(policyTY.ResourceSource, joinIDs(kv[types.KeyGatewayID], kv[types.KeyNodeID], kv[types.KeySourceID])), nil + case quickIdUL.QuickIdField: + return FormatResource(policyTY.ResourceField, joinIDs( + kv[types.KeyGatewayID], kv[types.KeyNodeID], kv[types.KeySourceID], kv[types.KeyFieldID], + )), nil + case quickIdUL.QuickIdTask: + return FormatResource(policyTY.ResourceTask, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + case quickIdUL.QuickIdSchedule: + return FormatResource(policyTY.ResourceSchedule, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + case quickIdUL.QuickIdHandler: + return FormatResource(policyTY.ResourceHandler, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + case quickIdUL.QuickIdFirmware: + return FormatResource(policyTY.ResourceFirmware, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + case quickIdUL.QuickIdDataRepository, "datarepository": + return FormatResource(policyTY.ResourceDataRepository, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + case quickIdUL.QuickIdForwardPayload, "forwardpayload": + return FormatResource(policyTY.ResourceForwardPayload, firstNonEmpty(kv[types.KeyID], kv["id"])), nil + default: + // unknown: fall back to kind:rest so custom kinds still gate somehow + if i := strings.Index(quickID, ":"); i > 0 { + return FormatResource(normalizeKind(quickID[:i]), quickID[i+1:]), nil + } + return "", fmt.Errorf("unsupported quick id type: %s", rt) + } +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/pkg/api/policy/quickid_auth_test.go b/pkg/api/policy/quickid_auth_test.go new file mode 100644 index 0000000..43a61be --- /dev/null +++ b/pkg/api/policy/quickid_auth_test.go @@ -0,0 +1,90 @@ +package policy + +import ( + "net/http" + "net/http/httptest" + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" +) + +func TestResourceFromQuickID(t *testing.T) { + cases := []struct { + in, want string + }{ + {"field:mysensor.1.3.V_VAR1", "field:mysensor.1.3.V_VAR1"}, + {"node:mysensor.2", "node:mysensor.2"}, + {"source:mysensor.2.4", "source:mysensor.2.4"}, + {"gateway:mysensor", "gateway:mysensor"}, + {"task:night-mode", "task:night-mode"}, + } + for _, c := range cases { + got, err := ResourceFromQuickID(c.in) + if err != nil { + t.Fatalf("%s: %v", c.in, err) + } + if got != c.want { + t.Errorf("%s: got %q want %q", c.in, got, c.want) + } + } +} + +func TestAuthorizeQuickIDRequest_DeniesNode1Field(t *testing.T) { + p := policyTY.Policy{ + ID: "test", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"get", "list"}, + Resources: []string{ + "gateway:mysensor", + "node:mysensor.*", + "quickid", + }, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"get", "list"}, + Resources: []string{"node:mysensor.1"}, + }, + }, + } + c := newCache() + c.PutUser(&userTY.User{ID: "u1", Policies: []string{"test"}}) + cp := p + c.PutPolicy(&cp) + c.setLoaders( + func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, + func(id string) (*policyTY.Policy, error) { return nil, ErrUserNotFound }, + func(id string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func() ([]policyTY.Policy, error) { return nil, nil }, + ) + a := &API{cache: c} + sub := Subject{UserID: "u1"} + + // bare quickid without ids: allowed (coarse) + r0 := httptest.NewRequest(http.MethodGet, "/api/quickid", nil) + if err := a.AuthorizeQuickIDRequest(sub, r0); err != nil { + t.Fatalf("bare quickid: %v", err) + } + + // field under denied node + r1 := httptest.NewRequest(http.MethodGet, "/api/quickid?id=field:mysensor.1.3.V_VAR1", nil) + if err := a.AuthorizeQuickIDRequest(sub, r1); err == nil { + t.Fatal("expected deny field under node 1 via quickid") + } + + // field under allowed node + r2 := httptest.NewRequest(http.MethodGet, "/api/quickid?id=field:mysensor.2.4.V_STATUS", nil) + if err := a.AuthorizeQuickIDRequest(sub, r2); err != nil { + t.Fatalf("expected allow field under node 2: %v", err) + } + + // multi-id with one denied → fail whole request + r3 := httptest.NewRequest(http.MethodGet, "/api/quickid?id=field:mysensor.2.4.V_STATUS&id=field:mysensor.1.3.V_VAR1", nil) + if err := a.AuthorizeQuickIDRequest(sub, r3); err == nil { + t.Fatal("expected deny when any id is denied") + } +} diff --git a/pkg/api/policy/resolve.go b/pkg/api/policy/resolve.go new file mode 100644 index 0000000..79db285 --- /dev/null +++ b/pkg/api/policy/resolve.go @@ -0,0 +1,126 @@ +package policy + +import ( + types "github.com/mycontroller-org/server/v2/pkg/types" + dashboardTY "github.com/mycontroller-org/server/v2/pkg/types/dashboard" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + fwdPayloadTY "github.com/mycontroller-org/server/v2/pkg/types/forward_payload" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + schedulerTY "github.com/mycontroller-org/server/v2/pkg/types/scheduler" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + taskTY "github.com/mycontroller-org/server/v2/pkg/types/task" + vdTY "github.com/mycontroller-org/server/v2/pkg/types/virtual_device" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + gatewayTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + handlerTY "github.com/mycontroller-org/server/v2/plugin/handler/types" + vaTY "github.com/mycontroller-org/server/v2/plugin/virtual_assistant/types" +) + +// ResolveResource rewrites access.Resource when path id is a storage UUID (or id) +// so policy match uses business names (e.g. gatewayId.nodeId) instead of UUID. +func (a *API) ResolveResource(access *RequestAccess) { + if access == nil || access.Name == "" || access.Kind == "" { + return + } + biz, err := a.ResolveBusinessName(access.Kind, access.Name) + if err != nil || biz == "" { + return + } + access.Name = biz + access.Resource = FormatResource(access.Kind, biz) +} + +// ResolveBusinessName loads the entity by storage id and returns the policy resource name. +func (a *API) ResolveBusinessName(kind, storageID string) (string, error) { + if storageID == "" || a.storage == nil { + return "", nil + } + filters := []storageTY.Filter{{Key: types.KeyID, Value: storageID}} + + switch kind { + case policyTY.ResourceGateway: + var e gatewayTY.Config + if err := a.storage.FindOne(types.EntityGateway, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceNode: + var e nodeTY.Node + if err := a.storage.FindOne(types.EntityNode, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceSource: + var e sourceTY.Source + if err := a.storage.FindOne(types.EntitySource, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceField: + var e fieldTY.Field + if err := a.storage.FindOne(types.EntityField, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceTask: + var e taskTY.Config + if err := a.storage.FindOne(types.EntityTask, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceSchedule: + var e schedulerTY.Config + if err := a.storage.FindOne(types.EntitySchedule, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceHandler: + var e handlerTY.Config + if err := a.storage.FindOne(types.EntityHandler, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceDashboard: + var e dashboardTY.Config + if err := a.storage.FindOne(types.EntityDashboard, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceFirmware: + var e firmwareTY.Firmware + if err := a.storage.FindOne(types.EntityFirmware, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceForwardPayload: + var e fwdPayloadTY.Config + if err := a.storage.FindOne(types.EntityForwardPayload, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceDataRepository: + var e dataRepoTY.Config + if err := a.storage.FindOne(types.EntityDataRepository, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceVirtualDevice: + var e vdTY.VirtualDevice + if err := a.storage.FindOne(types.EntityVirtualDevice, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + case policyTY.ResourceVirtualAssistant: + var e vaTY.Config + if err := a.storage.FindOne(types.EntityVirtualAssistant, &e, filters); err != nil { + return "", err + } + return BusinessName(kind, e), nil + default: + // unknown kind: keep path id as resource name + return storageID, nil + } +} diff --git a/pkg/api/policy/sleeping_queue_auth.go b/pkg/api/policy/sleeping_queue_auth.go new file mode 100644 index 0000000..3616459 --- /dev/null +++ b/pkg/api/policy/sleeping_queue_auth.go @@ -0,0 +1,36 @@ +package policy + +import ( + "fmt" + "net/http" + "strings" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +// AuthorizeSleepingQueueRequest checks the gateway/node named in the query. +// A kind-level grant on "gateway" must not be enough to read or clear another +// gateway's queue. +func (a *API) AuthorizeSleepingQueueRequest(subject Subject, r *http.Request) error { + gatewayID := strings.TrimSpace(r.URL.Query().Get("gatewayId")) + if gatewayID == "" { + return ErrAccessDenied + } + action := policyTY.ActionGet + if strings.Contains(strings.TrimSuffix(r.URL.Path, "/"), "/clear") { + action = policyTY.ActionAction + } + nodeID := strings.TrimSpace(r.URL.Query().Get("nodeId")) + if nodeID != "" { + resource := FormatResource(policyTY.ResourceNode, gatewayID+"."+nodeID) + if err := a.Allowed(subject, action, resource); err != nil { + return fmt.Errorf("%s on %s: %w", action, resource, err) + } + return nil + } + resource := FormatResource(policyTY.ResourceGateway, gatewayID) + if err := a.Allowed(subject, action, resource); err != nil { + return fmt.Errorf("%s on %s: %w", action, resource, err) + } + return nil +} diff --git a/pkg/api/policy/sleeping_queue_auth_test.go b/pkg/api/policy/sleeping_queue_auth_test.go new file mode 100644 index 0000000..d96a2d1 --- /dev/null +++ b/pkg/api/policy/sleeping_queue_auth_test.go @@ -0,0 +1,56 @@ +package policy + +import ( + "net/http" + "net/http/httptest" + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" +) + +func TestAuthorizeSleepingQueueRequestNamedGrant(t *testing.T) { + a := apiWithPolicies(t, singleGatewayPolicy()) + subject := Subject{UserID: "u1"} + + ok := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue?gatewayId=gw1", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, ok); err != nil { + t.Fatalf("gw1: %v", err) + } + + other := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue?gatewayId=gw2", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, other); err == nil { + t.Fatal("expected deny for gw2") + } + + missing := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, missing); err == nil { + t.Fatal("expected deny when gatewayId is missing") + } + + clearOther := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue/clear?gatewayId=gw2", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, clearOther); err == nil { + t.Fatal("expected deny clear for gw2") + } +} + +func TestAuthorizeSleepingQueueRequestNodeTarget(t *testing.T) { + a := apiWithPolicies(t, policyTY.Policy{ + ID: "one-node", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{policyTY.ActionGet, policyTY.ActionAction}, + Resources: []string{"node:gw1.n1"}, + }}, + }) + subject := Subject{UserID: "u1"} + + ok := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue?gatewayId=gw1&nodeId=n1", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, ok); err != nil { + t.Fatalf("node n1: %v", err) + } + + other := httptest.NewRequest(http.MethodGet, "/api/gateway-sleeping-queue?gatewayId=gw1&nodeId=n2", nil) + if err := a.AuthorizeSleepingQueueRequest(subject, other); err == nil { + t.Fatal("expected deny for node n2") + } +} diff --git a/pkg/api/policy/token_scope_test.go b/pkg/api/policy/token_scope_test.go new file mode 100644 index 0000000..455d13a --- /dev/null +++ b/pkg/api/policy/token_scope_test.go @@ -0,0 +1,145 @@ +package policy + +import ( + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" +) + +// tokenScopeAPI builds an API with one user, one policy and one service token in cache. +func tokenScopeAPI(t *testing.T, p policyTY.Policy, tokenResources []string) *API { + t.Helper() + c := newCache() + c.PutUser(&userTY.User{ID: "u1", Username: "u", Policies: []string{p.ID}}) + cp := p + c.PutPolicy(&cp) + c.PutToken(&svcTokenTY.ServiceToken{ + ID: "t-entity", + UserID: "u1", + NeverExpire: true, + Token: svcTokenTY.Token{ID: "t1"}, + Resources: tokenResources, + }) + c.setLoaders( + func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, + func(id string) (*policyTY.Policy, error) { return nil, ErrNotFound }, + func(tokenID string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func() ([]policyTY.Policy, error) { return nil, nil }, + ) + return &API{cache: c} +} + +// A service token must only be able to narrow the user's scope, never widen it. +func TestResourceNamesForList_TokenCanOnlyNarrow(t *testing.T) { + userPolicy := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"field:mysensor.*"}, + }}, + } + + cases := []struct { + name string + tokenResources []string + wantAllow []string + wantDenied bool + }{ + { + name: "token narrows to one field", + tokenResources: []string{"field:mysensor.1.s.temp"}, + wantAllow: []string{"mysensor.1.s.temp"}, + }, + { + name: "token names a gateway outside the user scope", + tokenResources: []string{"gateway:other-gw"}, + wantAllow: nil, // list api reachable, but no overlap -> no rows + }, + { + name: "token names an unrelated kind only", + tokenResources: []string{"task:night-mode"}, + wantDenied: true, // token does not reach this kind at all + }, + { + name: "token parent gateway cascades to fields", + tokenResources: []string{"gateway:mysensor"}, + wantAllow: []string{"mysensor"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := tokenScopeAPI(t, userPolicy, tc.tokenResources) + subject := Subject{UserID: "u1", ServiceTokenID: "t1"} + + unrestricted, patterns, err := a.ResourceNamesForList(subject, policyTY.ResourceField) + if tc.wantDenied { + if err == nil { + t.Fatalf("expected access denied, got patterns %v", patterns) + } + return + } + if err != nil { + t.Fatalf("ResourceNamesForList: %v", err) + } + if unrestricted { + t.Fatal("token restricted subject must never be unrestricted") + } + allow, _ := splitAllowDenyPatterns(patterns) + if len(allow) != len(tc.wantAllow) { + t.Fatalf("allow patterns = %v, want %v", allow, tc.wantAllow) + } + for i := range tc.wantAllow { + if allow[i] != tc.wantAllow[i] { + t.Fatalf("allow patterns = %v, want %v", allow, tc.wantAllow) + } + } + + // an empty scope must produce a query that returns nothing, + // not a query without scope filters + _, filters, err := a.StorageFiltersForList(subject, policyTY.ResourceField) + if err != nil { + t.Fatalf("StorageFiltersForList: %v", err) + } + if len(filters) == 0 { + t.Fatal("expected scope filters, got none (would list every row)") + } + }) + } +} + +// Deny patterns must not survive alone when the token empties the allow scope: +// a deny-only pattern list means "everything except ..." downstream. +func TestResourceNamesForList_TokenEmptiesScopeDropsDenyOnly(t *testing.T) { + userPolicy := policyTY.Policy{ + ID: "p1", + Statements: []policyTY.Statement{ + { + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: []string{"field:mysensor.*"}, + }, + { + Effect: policyTY.EffectDeny, + Actions: []string{"*"}, + Resources: []string{"field:mysensor.1.s.f"}, + }, + }, + } + // token reaches the field kind (gateway is an ancestor) but names a gateway + // the user policy does not cover + a := tokenScopeAPI(t, userPolicy, []string{"gateway:other-gw"}) + subject := Subject{UserID: "u1", ServiceTokenID: "t1"} + + _, patterns, err := a.ResourceNamesForList(subject, policyTY.ResourceField) + if err != nil { + t.Fatalf("ResourceNamesForList: %v", err) + } + allow, deny := splitAllowDenyPatterns(patterns) + if len(allow) != 0 || len(deny) != 0 { + t.Fatalf("expected empty scope, got allow=%v deny=%v", allow, deny) + } +} diff --git a/pkg/api/service_token/api.go b/pkg/api/service_token/api.go index 0545fac..bcfc007 100644 --- a/pkg/api/service_token/api.go +++ b/pkg/api/service_token/api.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + policyAPI "github.com/mycontroller-org/server/v2/pkg/api/policy" types "github.com/mycontroller-org/server/v2/pkg/types" svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" "github.com/mycontroller-org/server/v2/pkg/utils" @@ -28,6 +29,13 @@ func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *Ser } } +func (st *ServiceTokenAPI) notifyCache(token *svcTokenTY.ServiceToken) { + if token == nil { + return + } + policyAPI.New(st.ctx, st.logger, st.storage).NotifyTokenUpdated(token) +} + // List by filter and pagination func (st *ServiceTokenAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { result := make([]svcTokenTY.ServiceToken, 0) @@ -80,19 +88,40 @@ func (st *ServiceTokenAPI) Save(token *svcTokenTY.ServiceToken) error { if err != nil { return fmt.Errorf("unable to get token with id:%s, error:%s", token.ID, err.Error()) } + // user tie-up is immutable token.UserID = oldToken.UserID token.Token = oldToken.Token } + if token.UserID == "" { + return errors.New("user id can not be empty") + } + if token.Actions == nil { + token.Actions = []string{} + } + if token.Resources == nil { + token.Resources = []string{} + } filters := []storageTY.Filter{ {Key: types.KeyID, Value: token.ID}, } - return st.storage.Upsert(types.EntityServiceToken, token, filters) + if err := st.storage.Upsert(types.EntityServiceToken, token, filters); err != nil { + return err + } + st.notifyCache(token) + return nil } // Delete items func (st *ServiceTokenAPI) Delete(IDs []string) (int64, error) { + // load token ids for cache invalidation + pac := policyAPI.New(st.ctx, st.logger, st.storage) + for _, id := range IDs { + if t, err := st.GetByID(id); err == nil { + pac.NotifyTokenDeleted(t.ID, t.Token.ID) + } + } filters := []storageTY.Filter{{Key: types.KeyID, Operator: storageTY.OperatorIn, Value: IDs}} return st.storage.Delete(types.EntityServiceToken, filters) } @@ -115,11 +144,22 @@ func (st *ServiceTokenAPI) Create(newToken *svcTokenTY.ServiceToken) (*svcTokenT newToken.Token = svcTokenTY.Token{ID: generatedToken.ID, Token: hashedToken} newToken.CreatedOn = time.Now() + if newToken.Actions == nil { + newToken.Actions = []string{} + } + if newToken.Resources == nil { + newToken.Resources = []string{} + } - err = st.Save(newToken) - if err != nil { + // Save would try to reload by ID - for create set ID first then upsert without old-token branch + if newToken.ID == "" { + newToken.ID = utils.RandUUID() + } + filters := []storageTY.Filter{{Key: types.KeyID, Value: newToken.ID}} + if err := st.storage.Upsert(types.EntityServiceToken, newToken, filters); err != nil { return nil, fmt.Errorf("error on saving token:%s", err.Error()) } + st.notifyCache(newToken) // returns generated token return &svcTokenTY.CreateTokenResponse{ID: newToken.ID, Token: generatedToken.GetTokenWithID()}, nil @@ -137,7 +177,11 @@ func (st *ServiceTokenAPI) Import(data interface{}) error { filters := []storageTY.Filter{ {Key: types.KeyID, Value: input.ID}, } - return st.storage.Upsert(types.EntityServiceToken, &input, filters) + if err := st.storage.Upsert(types.EntityServiceToken, &input, filters); err != nil { + return err + } + st.notifyCache(&input) + return nil } func (st *ServiceTokenAPI) GetEntityInterface() interface{} { diff --git a/pkg/api/user/api.go b/pkg/api/user/api.go index 0b81cd4..ac1d6e7 100644 --- a/pkg/api/user/api.go +++ b/pkg/api/user/api.go @@ -7,7 +7,9 @@ import ( "strings" "time" + policyAPI "github.com/mycontroller-org/server/v2/pkg/api/policy" types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" "github.com/mycontroller-org/server/v2/pkg/utils" "github.com/mycontroller-org/server/v2/pkg/utils/hashed" @@ -29,6 +31,14 @@ func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *Use } } +func (u *UserAPI) notifyCache(user *userTY.User) { + if user == nil { + return + } + // keep access-control cache in sync + policyAPI.New(u.ctx, u.logger, u.storage).NotifyUserUpdated(user) +} + // List by filter and pagination func (u *UserAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { result := make([]userTY.User, 0) @@ -77,18 +87,90 @@ func (u *UserAPI) Save(user *userTY.User) error { if user.ID == "" { user.ID = utils.RandUUID() } + if user.Policies == nil { + user.Policies = []string{} + } + // default new users without policies get admin only when list is empty on first create - + // callers should set policies; migration assigns admin for empty. filters := []storageTY.Filter{ {Key: types.KeyID, Value: user.ID}, } user.ModifiedOn = time.Now() - return u.storage.Upsert(types.EntityUser, user, filters) + if err := u.storage.Upsert(types.EntityUser, user, filters); err != nil { + return err + } + u.notifyCache(user) + return nil } // Delete items func (u *UserAPI) Delete(IDs []string) (int64, error) { filters := []storageTY.Filter{{Key: types.KeyID, Operator: storageTY.OperatorIn, Value: IDs}} - return u.storage.Delete(types.EntityUser, filters) + n, err := u.storage.Delete(types.EntityUser, filters) + if err != nil { + return n, err + } + pac := policyAPI.New(u.ctx, u.logger, u.storage) + for _, id := range IDs { + pac.NotifyUserDeleted(id) + } + return n, nil +} + +// Create creates a new user with plain password and optional policies. +func (u *UserAPI) Create(user *userTY.User, plainPassword string) error { + if user.Username == "" { + return errors.New("username can not be empty") + } + if plainPassword == "" { + return errors.New("password can not be empty") + } + hashedPassword, err := hashed.GenerateHash(plainPassword) + if err != nil { + return err + } + user.Password = hashedPassword + if len(user.Policies) == 0 { + user.Policies = []string{policyTY.PolicyReadOnly} + } + user.ID = "" + return u.Save(user) +} + +// SaveAdmin updates user including disabled flag and policies (admin path). +func (u *UserAPI) SaveAdmin(update *userTY.UserAdminUpdate) error { + if update.ID == "" { + return errors.New("user id can not be empty") + } + user, err := u.GetByID(update.ID) + if err != nil { + return err + } + if update.Username != "" { + user.Username = update.Username + } + if update.Email != "" { + user.Email = update.Email + } + user.FullName = update.FullName + if update.Disabled != nil { + user.Disabled = *update.Disabled + } + if update.Policies != nil { + user.Policies = update.Policies + } + if update.Labels != nil { + user.Labels = update.Labels + } + if strings.TrimSpace(update.Password) != "" { + hashedPassword, err := hashed.GenerateHash(update.Password) + if err != nil { + return err + } + user.Password = hashedPassword + } + return u.Save(&user) } // UpdateProfile updates the user profile @@ -130,6 +212,7 @@ func (u *UserAPI) UpdateProfile(userData *userTY.UserProfileUpdate) error { user.FullName = userData.FullName user.Labels = userData.Labels + // profile update does not change Disabled or Policies return u.Save(&user) } @@ -142,11 +225,18 @@ func (u *UserAPI) Import(data interface{}) error { if input.ID == "" { input.ID = utils.RandUUID() } + if input.Policies == nil { + input.Policies = []string{} + } filters := []storageTY.Filter{ {Key: types.KeyID, Value: input.ID}, } - return u.storage.Upsert(types.EntityUser, &input, filters) + if err := u.storage.Upsert(types.EntityUser, &input, filters); err != nil { + return err + } + u.notifyCache(&input) + return nil } func (u *UserAPI) GetEntityInterface() interface{} { diff --git a/pkg/backup/backup_map.go b/pkg/backup/backup_map.go index 5b603cc..36833ed 100644 --- a/pkg/backup/backup_map.go +++ b/pkg/backup/backup_map.go @@ -35,6 +35,7 @@ func GetStorageApiMap(ctx context.Context) (map[string]backupTY.Backup, error) { types.EntityVirtualAssistant: entities.VirtualAssistant(), types.EntityVirtualDevice: entities.VirtualDevice(), types.EntityServiceToken: entities.ServiceToken(), + types.EntityPolicy: entities.Policy(), } return funcMap, nil diff --git a/pkg/http_router/handler.go b/pkg/http_router/handler.go index 5936edc..48f770c 100644 --- a/pkg/http_router/handler.go +++ b/pkg/http_router/handler.go @@ -16,6 +16,7 @@ import ( webConsole "github.com/mycontroller-org/server/v2/pkg/http_router/web-console" "github.com/mycontroller-org/server/v2/pkg/types/config" webHandlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" + handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" loggerUtils "github.com/mycontroller-org/server/v2/pkg/utils/logger" busTY "github.com/mycontroller-org/server/v2/plugin/bus/types" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" @@ -57,18 +58,50 @@ func New(ctx context.Context, cfg *config.Config, router *mux.Router) (http.Hand return nil, err } - // register application api routes - _, err = routes.New(ctx, router, webCfg.EnableProfiling) - if err != nil { + // wire access control (policies + user cache) into auth middleware + _policyAPI := coreApi.Policy() + if err := _policyAPI.EnsureBuiltInPolicies(); err != nil { + namedLogger.Error("error on ensuring built-in policies", zap.Error(err)) return nil, err } + // Users with no policies have no access. Report them instead of backfilling here: + // the pre-RBAC migration (upgrade 2.2.0-1) is the only place allowed to grant admin, + // so an intentionally stripped user is never silently re-promoted on restart. + if err := _policyAPI.ReportUsersWithoutPolicies(); err != nil { + namedLogger.Error("error on verifying user policies", zap.Error(err)) + return nil, err + } + middleware.SetAccessControl(_policyAPI) + // List APIs: AND resource-scope filters into the storage query (not post-filter) + handlerUtils.SetListQueryScope(func(r *http.Request, kind string) ([]storageTY.Filter, error) { + // identity comes from the verified request context, never from a header + // (headers are only sanitized on the authenticated path) + subject, err := middleware.SubjectFromRequest(r) + if err != nil { + return nil, err + } + unrestricted, filters, err := _policyAPI.StorageFiltersForList(subject, kind) + if err != nil { + return nil, err + } + if unrestricted { + return nil, nil + } + return filters, nil + }) - // register authentication routes, used in google, alexa and others + // Auth routes first so /api/user/profile is not captured by /api/user/{id} _authRoutes := authRoutes.NewAuthRoutes(logger, coreApi, router) _oAuthRoutes := authRoutes.NewOAuthRoutes(logger, coreApi, router) _authRoutes.RegisterRoutes() _oAuthRoutes.RegisterRoutes() + // register application api routes + _, err = routes.New(ctx, router, webCfg.EnableProfiling) + if err != nil { + return nil, err + } + // add secure and insecure directories into handler addFileServers(namedLogger, cfg.Directories, router) diff --git a/pkg/http_router/middleware/auth.go b/pkg/http_router/middleware/auth.go index ae48812..98fedcb 100644 --- a/pkg/http_router/middleware/auth.go +++ b/pkg/http_router/middleware/auth.go @@ -6,10 +6,13 @@ import ( "fmt" "net/http" "strings" + "sync" "time" jwt "github.com/golang-jwt/jwt/v5" + policyAPI "github.com/mycontroller-org/server/v2/pkg/api/policy" "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" "github.com/mycontroller-org/server/v2/pkg/types/user" handlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" "github.com/mycontroller-org/server/v2/pkg/utils/convertor" @@ -28,21 +31,45 @@ var ( "/api/", // all api handlerTY.SecureShareDirWebHandlerPath, // web file secure share api } + // Unauthenticated endpoints, matched exactly. A prefix match here would also + // open anything that merely starts with one of these paths - e.g. + // /api/user/{id} for an id starting with "registration". nonRestrictedAPIs = []string{ - "/api/status", // reports mycontroller server status - "/api/user/registration", // register new user. TODO: this api not used. verify and remove this - "/api/user/login", // login api + "/api/status", // reports mycontroller server status + "/api/user/registration", // register new user. TODO: this api not used. verify and remove this + "/api/user/login", // login api + "/api/oauth/login", // oauth login api + "/api/oauth/token", // oauth token api + "/api/plugin/gateway", // gateway plugin api + } + // Unauthenticated path trees, matched by prefix (they serve directories). + nonRestrictedPrefixes = []string{ handlerTY.InsecureShareDirWebHandlerPath, // web file insecure share api - "/api/oauth/login", // oauth login api - "/api/oauth/token", // oauth token api - "/api/plugin/gateway", // gateway plugin api } + + accessControlMu sync.RWMutex + accessControl *policyAPI.API ) +// SetAccessControl wires the policy API used by auth middleware (user disabled, RBAC). +// Call once during server HTTP setup. +func SetAccessControl(api *policyAPI.API) { + accessControlMu.Lock() + accessControl = api + accessControlMu.Unlock() +} + +func getAccessControl() *policyAPI.API { + accessControlMu.RLock() + defer accessControlMu.RUnlock() + return accessControl +} + // struct used in api request type McApiContext struct { - Tenant string `json:"tenant" yaml:"tenant"` - UserID string `json:"userId" yaml:"userId"` + Tenant string `json:"tenant" yaml:"tenant"` + UserID string `json:"userId" yaml:"userId"` + ServiceTokenID string `json:"serviceTokenId" yaml:"serviceTokenId"` } // MiddlewareAuthenticationVerification verifies user auth details @@ -72,19 +99,28 @@ func MiddlewareAuthenticationVerification(next http.Handler) http.Handler { } if isSecurePrefix { - for _, aPath := range nonRestrictedAPIs { - if strings.HasPrefix(path, aPath) { - next.ServeHTTP(w, r) - return - } + if isNonRestricted(path) { + next.ServeHTTP(w, r) + return } // authentication required if mcApiContext, err := IsValidToken(r); err == nil { + // verify user still active (cached) and service token still valid + if err := verifyPrincipalActive(mcApiContext); err != nil { + w.Header().Set("Content-Type", "application/json") + handlerUtils.PostErrorResponse(w, "401 Unauthorized", http.StatusUnauthorized) + return + } + + // authorization (RBAC) - lightweight, uses in-memory cache + if err := authorizeRequest(mcApiContext, r); err != nil { + w.Header().Set("Content-Type", "application/json") + handlerUtils.PostErrorResponse(w, "403 Forbidden", http.StatusForbidden) + return + } - // include user details as context ctx := context.WithValue(r.Context(), contextKey, mcApiContext) reqWithCtx := r.WithContext(ctx) - next.ServeHTTP(w, reqWithCtx) return } @@ -97,6 +133,100 @@ func MiddlewareAuthenticationVerification(next http.Handler) http.Handler { }) } +// isNonRestricted reports whether the path may be served without authentication. +func isNonRestricted(path string) bool { + trimmed := strings.TrimSuffix(path, "/") + for _, aPath := range nonRestrictedAPIs { + if trimmed == strings.TrimSuffix(aPath, "/") { + return true + } + } + for _, prefix := range nonRestrictedPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func verifyPrincipalActive(mc *McApiContext) error { + ac := getAccessControl() + if ac == nil { + // SetAccessControl runs before any route is served; an unwired middleware + // cannot authorize anything, so refuse rather than fall open + return errors.New("access control not initialized") + } + if mc.UserID == "" { + return errors.New("token has no user") + } + if _, err := ac.EnsureUserActive(mc.UserID); err != nil { + return err + } + if err := ac.EnsureServiceTokenActive(mc.UserID, mc.ServiceTokenID); err != nil { + return err + } + return nil +} + +// isOwnProfilePath reports whether the path is the self-service profile endpoint. +func isOwnProfilePath(path string) bool { + return strings.TrimSuffix(path, "/") == "/api/user/profile" +} + +func authorizeRequest(mc *McApiContext, r *http.Request) error { + ac := getAccessControl() + if ac == nil { + return errors.New("access control not initialized") + } + access := policyAPI.MapRequest(r) + if access.Skip { + return nil + } + // own profile always allowed for get/update of self. + // exact path only - a prefix match would also skip authorization for + // /api/user/{id} ids that happen to start with "profile" + if isOwnProfilePath(r.URL.Path) { + return nil + } + subject := policyAPI.Subject{UserID: mc.UserID, ServiceTokenID: mc.ServiceTokenID} + + // Metrics: enforce per target field/node/gateway (quick_id or body tags.id), not bare "metric" + if access.Kind == policyTY.ResourceMetric { + return ac.AuthorizeMetricRequest(subject, r) + } + + // QuickID: bare "quickid" is not enough — each ?id= target is checked as field:/node:/… + if access.Kind == policyTY.ResourceQuickID { + return ac.AuthorizeQuickIDRequest(subject, r) + } + + // Actions: targets come from the query string or the body, so bare "action" + // is not enough — every target is checked as node:/gateway:/field:/… + if strings.HasPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/api/action") { + return ac.AuthorizeActionRequest(subject, r) + } + + // Sleeping queue targets are gatewayId/nodeId query params, not the path. + if strings.HasPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/api/gateway-sleeping-queue") { + return ac.AuthorizeSleepingQueueRequest(subject, r) + } + + // Path ids are often storage UUIDs; policies use business names (gatewayId.nodeId...). + // Resolve entity so get/update/delete by UUID matches the same rules as list. + if access.Name != "" { + ac.ResolveResource(&access) + } + if err := ac.Allowed(subject, access.Action, access.Resource); err != nil { + return err + } + + // Collection endpoints carry their targets in the body (POST /api/gateway, + // POST /api/gateway/enable, DELETE /api/gateway ...). The check above only + // established that the principal may reach the endpoint; authorize each + // object it actually writes. + return ac.AuthorizeBodyTargets(subject, r, &access) +} + // steps to verify the authentication // 1. Verify the token in header // 2. Verify the token in cookie @@ -115,19 +245,30 @@ func IsValidToken(r *http.Request) (*McApiContext, error) { return nil, errors.New("expired token") } - // clear userID header, might be injected from external - // add userID into request header from here + // clear userID / svc token headers, might be injected from external r.Header.Del(handlerTY.HeaderUserID) - if userID, ok := claims[handlerTY.KeyUserID]; ok { - id, ok := userID.(string) - if ok { + r.Header.Del(handlerTY.HeaderServiceTokenID) + + userID := "" + if v, ok := claims[handlerTY.KeyUserID]; ok { + if id, ok := v.(string); ok { + userID = id r.Header.Set(handlerTY.HeaderUserID, id) } } + svcTokenID := "" + if v, ok := claims[handlerTY.KeyServiceTokenID]; ok { + if id, ok := v.(string); ok && id != "" { + svcTokenID = id + r.Header.Set(handlerTY.HeaderServiceTokenID, id) + } + } + mcApiContext := McApiContext{ - Tenant: "", - UserID: r.Header.Get(handlerTY.HeaderUserID), + Tenant: "", + UserID: userID, + ServiceTokenID: svcTokenID, } return &mcApiContext, nil @@ -186,6 +327,10 @@ func extractJwtToken(r *http.Request) string { // CreateToken creates a token for a user func CreateToken(user user.User, expiresIn, svcTokenID string) (string, error) { + if user.Disabled { + return "", errors.New("user is disabled") + } + atClaims := jwt.MapClaims{} atClaims[handlerTY.KeyAuthorized] = true atClaims[handlerTY.KeyUserID] = user.ID @@ -217,6 +362,34 @@ func GetUserID(r *http.Request) string { return r.Header.Get(handlerTY.HeaderUserID) } +// GetServiceTokenID returns service token id from request (if login used a service token) +func GetServiceTokenID(r *http.Request) string { + return r.Header.Get(handlerTY.HeaderServiceTokenID) +} + +// GetAPIContext returns McApiContext from request context if present +func GetAPIContext(r *http.Request) *McApiContext { + v := r.Context().Value(contextKey) + if v == nil { + return nil + } + if mc, ok := v.(*McApiContext); ok { + return mc + } + return nil +} + +// SubjectFromRequest builds the access control subject from the verified request +// context. Fails when the request did not pass authentication - the mc_userid +// header is only trusted after IsValidToken has rewritten it. +func SubjectFromRequest(r *http.Request) (policyAPI.Subject, error) { + mc := GetAPIContext(r) + if mc == nil || mc.UserID == "" { + return policyAPI.Subject{}, errors.New("unauthenticated request") + } + return policyAPI.Subject{UserID: mc.UserID, ServiceTokenID: mc.ServiceTokenID}, nil +} + func getJwtSecret() []byte { jwtSeed := types.GetEnvString(types.ENV_JWT_SEED) if jwtSeed == "" { diff --git a/pkg/http_router/middleware/auth_paths_test.go b/pkg/http_router/middleware/auth_paths_test.go new file mode 100644 index 0000000..d1632a8 --- /dev/null +++ b/pkg/http_router/middleware/auth_paths_test.go @@ -0,0 +1,51 @@ +package handler + +import ( + "testing" + + handlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" +) + +func TestIsNonRestricted(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"/api/status", true}, + {"/api/status/", true}, + {"/api/user/login", true}, + {"/api/oauth/token", true}, + // prefix look-alikes must stay authenticated: /api/user/{id} would match + // "/api/user/registration" as a prefix + {"/api/user/registrationX", false}, + {"/api/user/logins", false}, + {"/api/statuses", false}, + {"/api/user/profile", false}, + {"/api/gateway", false}, + {"/api/user/some-user-id", false}, + // share directories are path trees + {handlerTY.InsecureShareDirWebHandlerPath + "/some/file.txt", true}, + {handlerTY.SecureShareDirWebHandlerPath + "/some/file.txt", false}, + } + + for _, c := range cases { + if got := isNonRestricted(c.path); got != c.want { + t.Errorf("isNonRestricted(%q) = %v, want %v", c.path, got, c.want) + } + } +} + +func TestIsOwnProfilePath(t *testing.T) { + cases := map[string]bool{ + "/api/user/profile": true, + "/api/user/profile/": true, + "/api/user/profileX": false, + "/api/user/profile/x": false, + "/api/user/other-id": false, + } + for path, want := range cases { + if got := isOwnProfilePath(path); got != want { + t.Errorf("isOwnProfilePath(%q) = %v, want %v", path, got, want) + } + } +} diff --git a/pkg/http_router/routes/auth/auth.go b/pkg/http_router/routes/auth/auth.go index d55c7f8..3380b91 100644 --- a/pkg/http_router/routes/auth/auth.go +++ b/pkg/http_router/routes/auth/auth.go @@ -103,6 +103,11 @@ func (a *AuthRoutes) login(w http.ResponseWriter, r *http.Request) { userInDB = _userInDB } + if userInDB.Disabled { + handlerUtils.PostErrorResponse(w, "user is disabled", http.StatusUnauthorized) + return + } + token, err := middleware.CreateToken(userInDB, login.ExpiresIn, svcTokenID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusInternalServerError) @@ -148,6 +153,7 @@ func (a *AuthRoutes) profile(w http.ResponseWriter, r *http.Request) { user, err := a.api.User().GetByID(userID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusBadRequest) + return } handlerUtils.PostSuccessResponse(w, &user) } @@ -163,6 +169,7 @@ func (a *AuthRoutes) updateProfile(w http.ResponseWriter, r *http.Request) { user, err := a.api.User().GetByID(userID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusBadRequest) + return } entity := &userTY.UserProfileUpdate{} diff --git a/pkg/http_router/routes/auth/oauth.go b/pkg/http_router/routes/auth/oauth.go index bbc232e..5877013 100644 --- a/pkg/http_router/routes/auth/oauth.go +++ b/pkg/http_router/routes/auth/oauth.go @@ -98,7 +98,7 @@ func (oa *OAuthRoutes) login(w http.ResponseWriter, r *http.Request) { return } userInDB = _userInDB - svcTokenID = svcToken.ID + svcTokenID = svcToken.Token.ID } else { // user based authentication // get user details _userInDB, err := oa.api.User().GetByUsername(userLogin.Username) @@ -115,6 +115,11 @@ func (oa *OAuthRoutes) login(w http.ResponseWriter, r *http.Request) { userInDB = _userInDB } + if userInDB.Disabled { + handlerUtils.PostErrorResponse(w, "user is disabled", http.StatusUnauthorized) + return + } + accessToken, err := middleware.CreateToken(userInDB, userLogin.ExpiresIn, svcTokenID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/http_router/routes/policy.go b/pkg/http_router/routes/policy.go new file mode 100644 index 0000000..d217b20 --- /dev/null +++ b/pkg/http_router/routes/policy.go @@ -0,0 +1,62 @@ +package routes + +import ( + "errors" + "net/http" + + types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +func (h *Routes) registerPolicyRoutes() { + h.router.HandleFunc("/api/policy", h.listPolicies).Methods(http.MethodGet) + h.router.HandleFunc("/api/policy/{id}", h.getPolicy).Methods(http.MethodGet) + h.router.HandleFunc("/api/policy", h.updatePolicy).Methods(http.MethodPost) + h.router.HandleFunc("/api/policy", h.deletePolicies).Methods(http.MethodDelete) +} + +func (h *Routes) listPolicies(w http.ResponseWriter, r *http.Request) { + entityFn := func(f []storageTY.Filter, p *storageTY.Pagination) (interface{}, error) { + return h.api.Policy().List(f, p) + } + handlerUtils.LoadData(w, r, entityFn) +} + +func (h *Routes) getPolicy(w http.ResponseWriter, r *http.Request) { + handlerUtils.FindOne(h.storage, w, r, types.EntityPolicy, &policyTY.Policy{}) +} + +func (h *Routes) updatePolicy(w http.ResponseWriter, r *http.Request) { + entity := &policyTY.Policy{} + err := handlerUtils.LoadEntity(w, r, entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if entity.ID == "" { + http.Error(w, "id should not be an empty", http.StatusBadRequest) + return + } + err = h.api.Policy().Save(entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *Routes) deletePolicies(w http.ResponseWriter, r *http.Request) { + IDs := make([]string, 0) + updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { + if len(IDs) > 0 { + count, err := h.api.Policy().Delete(IDs) + if err != nil { + return nil, err + } + return count, nil + } + return nil, errors.New("supply id(s)") + } + handlerUtils.UpdateData(w, r, &IDs, updateFn) +} diff --git a/pkg/http_router/routes/routes.go b/pkg/http_router/routes/routes.go index 4a989e8..642949e 100644 --- a/pkg/http_router/routes/routes.go +++ b/pkg/http_router/routes/routes.go @@ -100,6 +100,7 @@ func New(ctx context.Context, router *mux.Router, enableProfiling bool) (*Routes routes.registerHandlerRoutes() routes.registerMetricRoutes() routes.registerNodeRoutes() + routes.registerPolicyRoutes() routes.registerQuickIDRoutes() routes.registerSchedulerRoutes() routes.registerServiceTokenRoutes() @@ -107,6 +108,7 @@ func New(ctx context.Context, router *mux.Router, enableProfiling bool) (*Routes routes.registerStatusRoutes() routes.registerSystemRoutes() routes.registerTaskRoutes() + routes.registerUserRoutes() routes.registerVirtualAssistantRoutes() routes.registerVirtualDeviceRoutes() diff --git a/pkg/http_router/routes/service_token.go b/pkg/http_router/routes/service_token.go index ed905bb..96673db 100644 --- a/pkg/http_router/routes/service_token.go +++ b/pkg/http_router/routes/service_token.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/gorilla/mux" middleware "github.com/mycontroller-org/server/v2/pkg/http_router/middleware" types "github.com/mycontroller-org/server/v2/pkg/types" svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" @@ -21,12 +22,44 @@ func (h *Routes) registerServiceTokenRoutes() { h.router.HandleFunc("/api/servicetoken", h.deleteServiceToken).Methods(http.MethodDelete) } +// Service tokens are personal credentials: a token acts as its owner. They are +// therefore always scoped to the caller, whatever the caller's policies say - +// otherwise one principal could read, widen (drop the action/resource limits, set +// neverExpire) or delete another principal's credentials. +func (h *Routes) ownedByCaller(r *http.Request) []storageTY.Filter { + return []storageTY.Filter{{Key: types.KeyUserID, Value: middleware.GetUserID(r)}} +} + func (h *Routes) listServiceToken(w http.ResponseWriter, r *http.Request) { - handlerUtils.FindMany(h.storage, w, r, types.EntityServiceToken, &[]svcTokenTY.ServiceToken{}) + entityFn := func(f []storageTY.Filter, p *storageTY.Pagination) (interface{}, error) { + return h.api.ServiceToken().List(append(f, h.ownedByCaller(r)...), p) + } + handlerUtils.LoadData(w, r, entityFn) } func (h *Routes) getServiceToken(w http.ResponseWriter, r *http.Request) { - handlerUtils.FindOne(h.storage, w, r, types.EntityServiceToken, &svcTokenTY.ServiceToken{}) + token, err := h.callerToken(r, mux.Vars(r)["id"]) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + handlerUtils.PostSuccessResponse(w, token) +} + +// callerToken loads a token and verifies the caller owns it. +func (h *Routes) callerToken(r *http.Request, id string) (*svcTokenTY.ServiceToken, error) { + if id == "" { + return nil, errors.New("id should not be an empty") + } + token, err := h.api.ServiceToken().GetByID(id) + if err != nil { + return nil, err + } + if token.UserID != middleware.GetUserID(r) { + // do not disclose that the id exists + return nil, errors.New("service token not found") + } + return &token, nil } func (h *Routes) updateServiceToken(w http.ResponseWriter, r *http.Request) { @@ -37,6 +70,10 @@ func (h *Routes) updateServiceToken(w http.ResponseWriter, r *http.Request) { return } + if _, err := h.callerToken(r, entity.ID); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } // update userId entity.UserID = middleware.GetUserID(r) @@ -71,14 +108,20 @@ func (h *Routes) createServiceToken(w http.ResponseWriter, r *http.Request) { func (h *Routes) deleteServiceToken(w http.ResponseWriter, r *http.Request) { IDs := []string{} updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { - if len(IDs) > 0 { - count, err := h.api.ServiceToken().Delete(IDs) - if err != nil { + if len(IDs) == 0 { + return nil, errors.New("supply id(s)") + } + // only the owner's tokens may be deleted + for _, id := range IDs { + if _, err := h.callerToken(r, id); err != nil { return nil, err } - return fmt.Sprintf("deleted: %d", count), nil } - return nil, errors.New("supply id(s)") + count, err := h.api.ServiceToken().Delete(IDs) + if err != nil { + return nil, err + } + return fmt.Sprintf("deleted: %d", count), nil } handlerUtils.UpdateData(w, r, &IDs, updateFn) } diff --git a/pkg/http_router/routes/user.go b/pkg/http_router/routes/user.go new file mode 100644 index 0000000..3b466a5 --- /dev/null +++ b/pkg/http_router/routes/user.go @@ -0,0 +1,121 @@ +package routes + +import ( + "errors" + "net/http" + "strings" + + "github.com/gorilla/mux" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +func (h *Routes) registerUserRoutes() { + h.router.HandleFunc("/api/user", h.listUsers).Methods(http.MethodGet) + // Skip reserved auth paths so they are not treated as user ids (e.g. "profile"). + // Match on the request path only - mux.Vars is not reliable inside MatcherFunc. + h.router.HandleFunc("/api/user/{id}", h.getUser).Methods(http.MethodGet). + MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool { + path := strings.TrimSuffix(r.URL.Path, "/") + switch path { + case "/api/user/profile", "/api/user/login", "/api/user/registration": + return false + default: + return true + } + }) + h.router.HandleFunc("/api/user", h.updateUser).Methods(http.MethodPost) + h.router.HandleFunc("/api/user", h.deleteUsers).Methods(http.MethodDelete) +} + +func (h *Routes) listUsers(w http.ResponseWriter, r *http.Request) { + entityFn := func(f []storageTY.Filter, p *storageTY.Pagination) (interface{}, error) { + result, err := h.api.User().List(f, p) + if err != nil { + return nil, err + } + // never expose password hashes over the api + if users, ok := result.Data.(*[]userTY.User); ok { + for index := range *users { + (*users)[index].Password = "" + } + } + return result, nil + } + handlerUtils.LoadData(w, r, entityFn) +} + +func (h *Routes) getUser(w http.ResponseWriter, r *http.Request) { + id, ok := mux.Vars(r)["id"] + if !ok || id == "" { + http.Error(w, "id should not be an empty", http.StatusBadRequest) + return + } + user, err := h.api.User().GetByID(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // never expose the password hash over the api + user.Password = "" + handlerUtils.PostSuccessResponse(w, &user) +} + +func (h *Routes) updateUser(w http.ResponseWriter, r *http.Request) { + entity := &userTY.UserAdminUpdate{} + err := handlerUtils.LoadEntity(w, r, entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if entity.ID == "" { + // create new user from admin update payload + disabled := false + if entity.Disabled != nil { + disabled = *entity.Disabled + } + user := &userTY.User{ + Username: entity.Username, + Email: entity.Email, + FullName: entity.FullName, + Disabled: disabled, + Policies: entity.Policies, + Labels: entity.Labels, + } + if entity.Password == "" { + http.Error(w, "password required for new user", http.StatusBadRequest) + return + } + // hash via SaveAdmin path - create then set password + if err := h.createUserWithPassword(user, entity.Password); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + err = h.api.User().SaveAdmin(entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *Routes) createUserWithPassword(user *userTY.User, plainPassword string) error { + return h.api.User().Create(user, plainPassword) +} + +func (h *Routes) deleteUsers(w http.ResponseWriter, r *http.Request) { + IDs := make([]string, 0) + updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { + if len(IDs) > 0 { + count, err := h.api.User().Delete(IDs) + if err != nil { + return nil, err + } + return count, nil + } + return nil, errors.New("supply id(s)") + } + handlerUtils.UpdateData(w, r, &IDs, updateFn) +} diff --git a/pkg/service/http_listener/https/ssl_test.go b/pkg/service/http_listener/https/ssl_test.go index f79824f..5c4e9aa 100644 --- a/pkg/service/http_listener/https/ssl_test.go +++ b/pkg/service/http_listener/https/ssl_test.go @@ -65,7 +65,7 @@ func TestShouldRegenerateCert_AboveThreshold(t *testing.T) { func TestShouldRegenerateCert_CustomRenewBeforeDays(t *testing.T) { logger := zap.NewNop() dir := t.TempDir() - // remaining validity: 15 days — regenerate only when threshold is higher than 15 + // remaining validity: 15 days - regenerate only when threshold is higher than 15 certFile, keyFile := writeTestCert(t, dir, time.Now().Add(-350*24*time.Hour), time.Now().Add(15*24*time.Hour)) if shouldRegenerateCert(logger, certFile, keyFile, 10) { diff --git a/pkg/types/entities.go b/pkg/types/entities.go index 84c35af..634dea1 100644 --- a/pkg/types/entities.go +++ b/pkg/types/entities.go @@ -18,6 +18,7 @@ const ( EntityVirtualDevice = "virtual_device" // holds virtual devices EntityVirtualAssistant = "virtual_assistant" // holds virtual assistants EntityServiceToken = "service_token" // holds service token + EntityPolicy = "policy" // access control policies ) // Entity field keys diff --git a/pkg/types/policy/types.go b/pkg/types/policy/types.go new file mode 100644 index 0000000..adc6c3f --- /dev/null +++ b/pkg/types/policy/types.go @@ -0,0 +1,109 @@ +package policy + +import ( + "strings" + "time" + + "github.com/mycontroller-org/server/v2/pkg/types/cmap" +) + +// Built-in policy IDs +const ( + PolicyAdmin = "admin" + PolicyReadWrite = "readwrite" + PolicyReadOnly = "readonly" +) + +// Effects +const ( + EffectAllow = "Allow" + EffectDeny = "Deny" // explicit deny wins over allow +) + +// Common actions +const ( + ActionGet = "get" + ActionList = "list" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionEnable = "enable" + ActionDisable = "disable" + ActionReload = "reload" + ActionAction = "action" // execute resource action + ActionAll = "*" +) + +// Common resource kinds (used in resource strings as kind:name) +const ( + ResourceGateway = "gateway" + ResourceNode = "node" + ResourceSource = "source" + ResourceField = "field" + ResourceTask = "task" + ResourceSchedule = "schedule" + ResourceHandler = "handler" + ResourceDashboard = "dashboard" + ResourceFirmware = "firmware" + ResourceForwardPayload = "forwardpayload" + ResourceDataRepository = "datarepository" + ResourceVirtualDevice = "virtualdevice" + ResourceVirtualAssistant = "virtualassistant" + ResourceServiceToken = "servicetoken" + ResourceSettings = "settings" + ResourceBackup = "backup" + ResourceUser = "user" + ResourcePolicy = "policy" + ResourceMetric = "metric" + ResourceAction = "action" + ResourceStatus = "status" + ResourceQuickID = "quickid" + ResourceAll = "*" +) + +// FormatSettingsResource builds the resource string for one settings document, +// e.g. "settings:system_settings". +func FormatSettingsResource(key string) string { + return ResourceSettings + ":" + key +} + +// NormalizeKind maps an api path segment or a storage entity name to a policy kind. +// Single source of truth: api paths use "forwardpayload", storage uses +// "forward_payload", policies use ResourceForwardPayload. Keeping one mapping +// prevents a kind from silently losing its access-control scope. +func NormalizeKind(value string) string { + s := strings.ToLower(strings.TrimSpace(value)) + switch s { + case "forward_payload", "forwardpayload": + return ResourceForwardPayload + case "data_repository", "datarepository": + return ResourceDataRepository + case "virtual_device", "virtualdevice": + return ResourceVirtualDevice + case "virtual_assistant", "virtualassistant": + return ResourceVirtualAssistant + case "service_token", "servicetoken": + return ResourceServiceToken + default: + return s + } +} + +// Policy is a named permission document attached to users (and optionally used when resolving access). +type Policy struct { + ID string `json:"id" yaml:"id"` + Description string `json:"description" yaml:"description"` + System bool `json:"system" yaml:"system"` // built-in; protect from delete + Statements []Statement `json:"statements" yaml:"statements"` + Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` + ModifiedOn time.Time `json:"modifiedOn" yaml:"modifiedOn"` +} + +// Statement grants actions on resources. +// Resource format: "kind" | "kind:name" | "kind:name.*" | "*" +// Examples: "field:home-gw.living-room.dht.temperature", "node:home-gw.*", "gateway:*", "*" +type Statement struct { + Effect string `json:"effect" yaml:"effect"` // Allow or Deny (Deny wins) + Actions []string `json:"actions" yaml:"actions"` + Resources []string `json:"resources" yaml:"resources"` +} diff --git a/pkg/types/service_token/types.go b/pkg/types/service_token/types.go index d6c6002..8c42953 100644 --- a/pkg/types/service_token/types.go +++ b/pkg/types/service_token/types.go @@ -13,14 +13,18 @@ import ( type ServiceToken struct { ID string `json:"id" yaml:"id"` - UserID string `json:"userId" yaml:"userId"` + UserID string `json:"userId" yaml:"userId"` // always tied to a user; permissions cannot exceed this user Name string `json:"name" yaml:"name"` Description string `json:"description" yaml:"description"` Token Token `json:"token" yaml:"token"` // keeps hashed token, not the actual token NeverExpire bool `json:"neverExpire" yaml:"neverExpire"` ExpiresOn dateTimeTY.CustomDate `json:"expiresOn" yaml:"expiresOn"` - Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` - CreatedOn time.Time `json:"createdOn" yaml:"createdOn"` + // Optional restrictions - empty means same access as the owning user. + // When set, effective access = user policies ∩ these limits (can only lower). + Actions []string `json:"actions" yaml:"actions"` + Resources []string `json:"resources" yaml:"resources"` + Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` + CreatedOn time.Time `json:"createdOn" yaml:"createdOn"` } type CreateTokenResponse struct { diff --git a/pkg/types/user/types.go b/pkg/types/user/types.go index 1ea6f87..295df6e 100644 --- a/pkg/types/user/types.go +++ b/pkg/types/user/types.go @@ -14,6 +14,8 @@ type User struct { Email string `json:"email" yaml:"email"` Password string `json:"password" yaml:"password"` // keep the hashed password, not the actual password FullName string `json:"fullName" yaml:"fullName"` + Disabled bool `json:"disabled" yaml:"disabled"` // when true, JWT and login are rejected + Policies []string `json:"policies" yaml:"policies"` // attached policy ids Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` ModifiedOn time.Time `json:"modifiedOn" yaml:"modifiedOn"` } @@ -40,3 +42,15 @@ type UserProfileUpdate struct { FullName string `json:"fullName" yaml:"fullName"` Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` } + +// UserAdminUpdate used when an admin updates another user (policies, disabled, profile fields) +type UserAdminUpdate struct { + ID string `json:"id" yaml:"id"` + Username string `json:"username" yaml:"username"` + Email string `json:"email" yaml:"email"` + FullName string `json:"fullName" yaml:"fullName"` + Disabled *bool `json:"disabled,omitempty" yaml:"disabled,omitempty"` + Policies []string `json:"policies" yaml:"policies"` + Password string `json:"password" yaml:"password"` // optional new password (plain); empty = keep + Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` +} diff --git a/pkg/types/web_handler/types.go b/pkg/types/web_handler/types.go index 2db47f9..fe8a17b 100644 --- a/pkg/types/web_handler/types.go +++ b/pkg/types/web_handler/types.go @@ -10,8 +10,9 @@ const ( KeyAuthorized = "authorized" KeyExpiresAt = "expires_at" - HeaderAuthorization = "Authorization" - HeaderUserID = "mc_userid" + HeaderAuthorization = "Authorization" + HeaderUserID = "mc_userid" + HeaderServiceTokenID = "mc_svc_token_id" AccessToken = "access_token" diff --git a/pkg/upgrade/v2_2_0__1.go b/pkg/upgrade/v2_2_0__1.go new file mode 100644 index 0000000..29a1acb --- /dev/null +++ b/pkg/upgrade/v2_2_0__1.go @@ -0,0 +1,29 @@ +package upgrade + +import ( + "context" + + entitiesAPI "github.com/mycontroller-org/server/v2/pkg/api/entities" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + "go.uber.org/zap" +) + +// RBAC introduction: create built-in policies and grant admin to existing users +// that have no policies attached (pre-RBAC installs). +func upgrade_2_2_0__1(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin, api *entitiesAPI.API) error { + policyAPI := api.Policy() + + if err := policyAPI.EnsureBuiltInPolicies(); err != nil { + logger.Error("error on creating built-in policies", zap.Error(err)) + return err + } + logger.Info("built-in access policies ensured (admin, readwrite, readonly)") + + if err := policyAPI.AssignAdminToUsersWithoutPolicies(); err != nil { + logger.Error("error on assigning admin policy to existing users", zap.Error(err)) + return err + } + logger.Info("existing users without policies assigned admin policy") + + return nil +} diff --git a/pkg/upgrade/versions.go b/pkg/upgrade/versions.go index b164033..f946891 100644 --- a/pkg/upgrade/versions.go +++ b/pkg/upgrade/versions.go @@ -16,4 +16,5 @@ type upgradeFunction = func(ctx context.Context, logger *zap.Logger, storage sto var upgrades = map[string]upgradeFunction{ "2.0.0-1": upgrade_2_0_0__1, // 2.0.0 upgrade #1 "2.1.1-1": upgrade_2_1_1__1, // 2.1.1 upgrade #2 + "2.2.0-1": upgrade_2_2_0__1, // 2.2.0: RBAC policies + admin for existing users } diff --git a/pkg/utils/filter_sort/utils_filter.go b/pkg/utils/filter_sort/utils_filter.go index ec53c89..77ec34f 100644 --- a/pkg/utils/filter_sort/utils_filter.go +++ b/pkg/utils/filter_sort/utils_filter.go @@ -48,6 +48,26 @@ func IsMatching(entity interface{}, filters []storageTY.Filter) bool { match := true for index := 0; index < len(filters); index++ { filter := filters[index] + + // OR of filter groups (used by RBAC list scoping) + if filter.Operator == storageTY.OperatorOr { + if !matchOrGroups(entity, filter.Value) { + match = false + break + } + continue + } + + // NOR of one AND-group (RBAC Deny exclude: NOT (gw=X AND node=Y)) + if filter.Operator == storageTY.OperatorNor { + if matchNorGroup(entity, filter.Value) { + // matchNorGroup true means entity IS in the denied set + match = false + break + } + continue + } + valKind, value, err := GetValueByKeyPath(entity, filter.Key) if err != nil { //zap.L().Debug("failed to get value", zap.Any("filter", filter), zap.Error(err)) @@ -81,6 +101,64 @@ func IsMatching(entity interface{}, filters []storageTY.Filter) bool { return match } +// matchOrGroups evaluates Value as [][]Filter (OR of AND-groups). +func matchOrGroups(entity interface{}, value interface{}) bool { + groups, ok := value.([][]storageTY.Filter) + if !ok { + // tolerate []interface{} from generic unmarshalling + raw, ok := value.([]interface{}) + if !ok { + return false + } + groups = make([][]storageTY.Filter, 0, len(raw)) + for _, g := range raw { + switch gg := g.(type) { + case []storageTY.Filter: + groups = append(groups, gg) + case []interface{}: + inner := make([]storageTY.Filter, 0, len(gg)) + for _, item := range gg { + if f, ok := item.(storageTY.Filter); ok { + inner = append(inner, f) + } + } + groups = append(groups, inner) + } + } + } + if len(groups) == 0 { + return false + } + for _, group := range groups { + if IsMatching(entity, group) { + return true + } + } + return false +} + +// matchNorGroup returns true if the entity matches the positive AND-group +// (i.e. should be excluded by OperatorNor). Value is []Filter. +func matchNorGroup(entity interface{}, value interface{}) bool { + group, ok := value.([]storageTY.Filter) + if !ok { + raw, ok := value.([]interface{}) + if !ok { + return false + } + group = make([]storageTY.Filter, 0, len(raw)) + for _, item := range raw { + if f, ok := item.(storageTY.Filter); ok { + group = append(group, f) + } + } + } + if len(group) == 0 { + return false + } + return IsMatching(entity, group) +} + // VerifyStringSlice implementation func VerifyStringSlice(value string, operator string, filterValue interface{}) bool { stringSlice, ok := filterValue.([]string) @@ -129,6 +207,12 @@ func CompareString(value interface{}, operator string, filterValue interface{}) return false } return compiled.MatchString(valueString) + case storageTY.OperatorRegexCaseSensitive: + compiled, err := regexp.Compile(converterUtils.ToString(filterValue)) + if err != nil { + return false + } + return compiled.MatchString(valueString) case storageTY.OperatorExists: return valueString != "" case storageTY.OperatorIn, storageTY.OperatorNotIn: diff --git a/pkg/utils/http_handler/handler_http_utils.go b/pkg/utils/http_handler/handler_http_utils.go index 4a1a64b..1452e6c 100644 --- a/pkg/utils/http_handler/handler_http_utils.go +++ b/pkg/utils/http_handler/handler_http_utils.go @@ -32,10 +32,18 @@ func ReceivedQueryMap(request *http.Request) (map[string][]string, error) { // Params func func Params(request *http.Request) ([]storageTY.Filter, *storageTY.Pagination, error) { - f := mux.Vars(request) q := request.URL.Query() + vars := mux.Vars(request) + f := make(map[string]string, len(q)+len(vars)) for key, value := range q { - f[key] = value[0] // TODO: FIX this to fetch all the values + if len(value) > 0 { + f[key] = value[0] + } + } + // Path variables win so GET /api/{kind}/{id}?id=other cannot load a + // different entity than the one authorized from the path. + for key, value := range vars { + f[key] = value } // get Pagination arguments diff --git a/pkg/utils/http_handler/handler_http_utils_test.go b/pkg/utils/http_handler/handler_http_utils_test.go new file mode 100644 index 0000000..e66637c --- /dev/null +++ b/pkg/utils/http_handler/handler_http_utils_test.go @@ -0,0 +1,45 @@ +package http_handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func TestParamsPathIDWinsOverQuery(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/gateway/gw1?id=gw2", nil) + r = mux.SetURLVars(r, map[string]string{"id": "gw1"}) + + filters, _, err := Params(r) + if err != nil { + t.Fatal(err) + } + var id string + for _, f := range filters { + if f.Key == "id" { + id = f.Value.(string) + } + } + if id != "gw1" { + t.Fatalf("path id should win, got %q", id) + } +} + +func TestParamsQueryUsedWhenNoPathVar(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/gateway?id=gw2", nil) + filters, _, err := Params(r) + if err != nil { + t.Fatal(err) + } + var id string + for _, f := range filters { + if f.Key == "id" { + id = f.Value.(string) + } + } + if id != "gw2" { + t.Fatalf("query id should apply on collection, got %q", id) + } +} diff --git a/pkg/utils/http_handler/handler_storage_utils.go b/pkg/utils/http_handler/handler_storage_utils.go index 9936ae6..d9e2ee0 100644 --- a/pkg/utils/http_handler/handler_storage_utils.go +++ b/pkg/utils/http_handler/handler_storage_utils.go @@ -3,8 +3,10 @@ package http_handler import ( "io" "net/http" + "strings" json "github.com/mycontroller-org/server/v2/pkg/json" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" ) @@ -42,6 +44,14 @@ func LoadData(w http.ResponseWriter, r *http.Request, entityFn func(f []storageT return } + // RBAC: inject resource-scope filters into the storage query + kind := kindFromRequest(r) + f, err = applyListQueryScope(r, kind, f) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + result, err := entityFn(f, p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -101,6 +111,14 @@ func FindMany(storage storageTY.Plugin, w http.ResponseWriter, r *http.Request, return } + // RBAC: inject resource-scope filters into the storage query + kind := EntityNameToKind(entityName) + f, err = applyListQueryScope(r, kind, f) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + result, err := storage.Find(entityName, entities, f, p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -114,6 +132,25 @@ func FindMany(storage storageTY.Plugin, w http.ResponseWriter, r *http.Request, WriteResponse(w, od) } +// EntityNameToKind maps a storage entity name to a policy kind. +func EntityNameToKind(entityName string) string { + return policyTY.NormalizeKind(entityName) +} + +// kindFromRequest derives the policy kind from the first api path segment. +func kindFromRequest(r *http.Request) string { + const prefix = "/api/" + path := r.URL.Path + if !strings.HasPrefix(path, prefix) { + return "" + } + segment := strings.TrimPrefix(path, prefix) + if index := strings.IndexByte(segment, '/'); index >= 0 { + segment = segment[:index] + } + return policyTY.NormalizeKind(segment) +} + // SaveEntity func func SaveEntity(storage storageTY.Plugin, w http.ResponseWriter, r *http.Request, entityName string, entity interface{}, bwFunc func(entity interface{}, filters *[]storageTY.Filter) error) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/utils/http_handler/list_rbac.go b/pkg/utils/http_handler/list_rbac.go new file mode 100644 index 0000000..dcbdfb2 --- /dev/null +++ b/pkg/utils/http_handler/list_rbac.go @@ -0,0 +1,41 @@ +package http_handler + +import ( + "net/http" + "sync" + + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +// ListQueryScope returns extra storage filters to AND into list queries (RBAC resource scope). +// Wired from HTTP setup once access control is ready. +type ListQueryScope func(r *http.Request, kind string) (filters []storageTY.Filter, err error) + +var ( + listScopeMu sync.RWMutex + listScope ListQueryScope +) + +// SetListQueryScope registers RBAC list scoping at query level (call at server HTTP init). +func SetListQueryScope(fn ListQueryScope) { + listScopeMu.Lock() + listScope = fn + listScopeMu.Unlock() +} + +func applyListQueryScope(r *http.Request, kind string, filters []storageTY.Filter) ([]storageTY.Filter, error) { + listScopeMu.RLock() + fn := listScope + listScopeMu.RUnlock() + if fn == nil || kind == "" { + return filters, nil + } + extra, err := fn(r, kind) + if err != nil { + return filters, err + } + if len(extra) == 0 { + return filters, nil + } + return append(filters, extra...), nil +} diff --git a/plugin/database/storage/mongodb/client.go b/plugin/database/storage/mongodb/client.go index ad0e4eb..eb7eb4b 100644 --- a/plugin/database/storage/mongodb/client.go +++ b/plugin/database/storage/mongodb/client.go @@ -266,49 +266,131 @@ func defaultFilter(filters []storageTY.Filter, data interface{}) *bson.M { return filter(filters) } +// matchNothing is a predicate no document can satisfy. Used when a compound +// filter cannot be decoded, so an unusable constraint fails closed. +func matchNothing() bson.M { + return bson.M{"_id": bson.M{"$in": []interface{}{}}} +} + func filter(filters []storageTY.Filter) *bson.M { bm := bson.M{} if len(filters) == 0 { return &bm } - for _, _f := range filters { - fl := strings.ToLower(_f.Key) - switch strings.ToLower(_f.Operator) { - case storageTY.OperatorNone: - bm[fl] = _f.Value - - case storageTY.OperatorEqual: - bm[fl] = bson.M{"$eq": _f.Value} - - case storageTY.OperatorNotEqual: - bm[fl] = bson.M{"$ne": _f.Value} + // Compound ops ($or / $nor) that must be AND-ed with field predicates. + // Multiple top-level OperatorOr must not be flattened into one $or. + andParts := make([]bson.M, 0) + // fields already placed in bm, to detect a second predicate on the same field + andedKeys := make(map[string]struct{}, len(filters)) - case storageTY.OperatorIn: - bm[fl] = bson.M{"$in": _f.Value} - - case storageTY.OperatorNotIn: - bm[fl] = bson.M{"$nin": _f.Value} - - case storageTY.OperatorGreaterThan: - bm[fl] = bson.M{"$gt": _f.Value} - - case storageTY.OperatorLessThan: - bm[fl] = bson.M{"$lt": _f.Value} + for _, _f := range filters { + op := strings.ToLower(_f.Operator) + + // OR of AND-groups (RBAC list allow scope) + if op == storageTY.OperatorOr { + groups, ok := _f.Value.([][]storageTY.Filter) + if !ok { + // unusable scope filter: match nothing rather than drop the + // constraint (these carry access-control scope) + andParts = append(andParts, matchNothing()) + continue + } + orClauses := make([]bson.M, 0, len(groups)) + for _, group := range groups { + sub := filter(group) + if sub != nil && len(*sub) > 0 { + orClauses = append(orClauses, *sub) + } + } + if len(orClauses) == 1 { + andParts = append(andParts, orClauses[0]) + } else if len(orClauses) > 1 { + andParts = append(andParts, bson.M{"$or": orClauses}) + } + continue + } - case storageTY.OperatorGreaterThanEqual: - bm[fl] = bson.M{"$gte": _f.Value} + // NOR of one AND-group (RBAC Deny exclude) + if op == storageTY.OperatorNor { + group, ok := _f.Value.([]storageTY.Filter) + if !ok { + andParts = append(andParts, matchNothing()) + continue + } + sub := filter(group) + if sub != nil && len(*sub) > 0 { + andParts = append(andParts, bson.M{"$nor": []bson.M{*sub}}) + } + continue + } - case storageTY.OperatorLessThanEqual: - bm[fl] = bson.M{"$lte": _f.Value} + fl := strings.ToLower(_f.Key) + predicate, ok := fieldPredicate(op, _f.Value) + if !ok { + continue + } + // Two filters on the same field must both apply. Merging them into one + // bson.M key would silently keep only the last one, which drops access + // control scope: an allow on id plus a deny on id would leave only the deny + // (= everything except), and a client filter could override a path id. + if existing, duplicate := bm[fl]; duplicate { + andParts = append(andParts, bson.M{fl: existing}, bson.M{fl: predicate}) + delete(bm, fl) + continue + } + if _, alreadyAnded := andedKeys[fl]; alreadyAnded { + andParts = append(andParts, bson.M{fl: predicate}) + continue + } + bm[fl] = predicate + andedKeys[fl] = struct{}{} + } - case storageTY.OperatorExists: - bm[fl] = bson.M{"$exists": _f.Value} + if len(andParts) == 0 { + return &bm + } + if len(bm) == 0 && len(andParts) == 1 { + return &andParts[0] + } + parts := make([]bson.M, 0, 1+len(andParts)) + if len(bm) > 0 { + parts = append(parts, bm) + } + parts = append(parts, andParts...) + return &bson.M{"$and": parts} +} - case storageTY.OperatorRegex: - bm[fl] = bson.M{"$regex": _f.Value, "$options": "i"} - } +// fieldPredicate converts one operator into its mongo predicate. +// ok is false for an unsupported operator (the filter is then ignored, as before). +func fieldPredicate(operator string, value interface{}) (interface{}, bool) { + switch operator { + case storageTY.OperatorNone: + return value, true + case storageTY.OperatorEqual: + return bson.M{"$eq": value}, true + case storageTY.OperatorNotEqual: + return bson.M{"$ne": value}, true + case storageTY.OperatorIn: + return bson.M{"$in": value}, true + case storageTY.OperatorNotIn: + return bson.M{"$nin": value}, true + case storageTY.OperatorGreaterThan: + return bson.M{"$gt": value}, true + case storageTY.OperatorLessThan: + return bson.M{"$lt": value}, true + case storageTY.OperatorGreaterThanEqual: + return bson.M{"$gte": value}, true + case storageTY.OperatorLessThanEqual: + return bson.M{"$lte": value}, true + case storageTY.OperatorExists: + return bson.M{"$exists": value}, true + case storageTY.OperatorRegex: + return bson.M{"$regex": value, "$options": "i"}, true + case storageTY.OperatorRegexCaseSensitive: + return bson.M{"$regex": value}, true + default: + return nil, false } - return &bm } func sort(sort []storageTY.Sort) *bson.M { diff --git a/plugin/database/storage/types/storage.go b/plugin/database/storage/types/storage.go index a50c1b4..5f8a0e7 100644 --- a/plugin/database/storage/types/storage.go +++ b/plugin/database/storage/types/storage.go @@ -77,7 +77,18 @@ const ( OperatorGreaterThanEqual = "gte" OperatorLessThanEqual = "lte" OperatorExists = "exists" - OperatorRegex = "regex" + OperatorRegex = "regex" // case insensitive, used by client supplied filters + // OperatorRegexCaseSensitive matches exactly as written. Access control scope + // uses it so a list query cannot return rows that a direct get would deny + // (resource names are compared case sensitively by the policy engine). + OperatorRegexCaseSensitive = "regex_cs" + // OperatorOr groups alternative filter sets (OR of AND-groups). + // Filter.Value must be [][]Filter (each inner slice is AND-ed; groups are OR-ed). + OperatorOr = "or" + // OperatorNor negates one AND-group. Filter.Value must be []Filter. + // Matches entities that do NOT satisfy all filters in the group. + // Used for RBAC Deny excludes (e.g. NOT (GatewayID=gw AND NodeID=1)). + OperatorNor = "nor" ) // Sort options