From ca0ced3be332a2b835c6bbe6c7226c17c0b134c3 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:27:54 +0100 Subject: [PATCH 01/34] Add LMS app template for fine-grained authorization --- source/templates/lms-app/README.md | 0 source/templates/lms-app/lmsapp.tf | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 source/templates/lms-app/README.md create mode 100644 source/templates/lms-app/lmsapp.tf diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md new file mode 100644 index 00000000..e69de29b diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf new file mode 100644 index 00000000..e69de29b From df76bb5ce8dfd3fc235e2ad992901f85361f92bb Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:44:32 +0100 Subject: [PATCH 02/34] Add LMS app template for fine-grained authorization --- source/templates/lms-app/README.md | 7 ++ source/templates/lms-app/lmsapp.tf | 189 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md index e69de29b..422c10be 100644 --- a/source/templates/lms-app/README.md +++ b/source/templates/lms-app/README.md @@ -0,0 +1,7 @@ +# LMS App Template +This template configures a Learning Management System (LMS) with resources (`course`, `enrollment`, `assignment`), roles (`student`, `teacher`, `teaching_assistant`, `admin`), and fine-grained policies (e.g., students read enrolled courses). + +## Usage +1. Install the Permit CLI: `npm install -g @permitio/cli` +2. Apply the template: `permit env template apply lms-app` +3. Replace `{{API_KEY}}` in `main.tf` with your Permit API key. \ No newline at end of file diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf index e69de29b..b8b321b5 100644 --- a/source/templates/lms-app/lmsapp.tf +++ b/source/templates/lms-app/lmsapp.tf @@ -0,0 +1,189 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.12" + } + } +} + +provider "permitio" { + api_url = "https://api.permit.io" + api_key = "{{API_KEY}}" +} + +# Resources +resource "permitio_resource" "Course" { + name = "Course" + description = "A course in the Learning Management System" + key = "Course" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + }, + "enroll" = { + name = "enroll" + } + } + attributes = { + "enrolledStudents" = { + name = "Enrolled Students" + type = "array" + }, + "teacherId" = { + name = "Teacher ID" + type = "string" + } + } +} + +resource "permitio_resource" "Enrollment" { + name = "Enrollment" + description = "Student enrollment in a course" + key = "Enrollment" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "delete" = { + name = "delete" + } + } + attributes = {} +} + +resource "permitio_resource" "Assignment" { + name = "Assignment" + description = "An assignment linked to a course" + key = "Assignment" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + }, + "grade" = { + name = "grade" + } + } + attributes = { + "dueDate" = { + name = "Due Date" + type = "string" + } + } +} + +# Roles +resource "permitio_role" "Student" { + key = "student" + name = "Student" + permissions = ["Course:read", "Enrollment:create", "Assignment:read", "Assignment:submit"] + + depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] +} + +resource "permitio_role" "Teacher" { + key = "teacher" + name = "Teacher" + permissions = ["Course:create", "Course:read", "Course:update", "Course:delete", "Assignment:read", "Assignment:grade"] + + depends_on = [permitio_resource.Course, permitio_resource.Assignment] +} + +resource "permitio_role" "Teaching_Assistant" { + key = "teaching_assistant" + name = "Teaching Assistant" + permissions = ["Course:read", "Course:update", "Assignment:read"] + + depends_on = [permitio_resource.Course, permitio_resource.Assignment] +} + +resource "permitio_role" "Admin" { + key = "admin" + name = "Admin" + permissions = [ + "Course:create", "Course:read", "Course:update", "Course:delete", + "Enrollment:create", "Enrollment:read", "Enrollment:delete", + "Assignment:create", "Assignment:read", "Assignment:update", "Assignment:delete", "Assignment:grade" + ] + + depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] +} + +# Relations +resource "permitio_relation" "Course_Teacher" { + key = "assigned_to" + name = "Assigned To" + subject_resource = permitio_resource.Course.key + object_resource = permitio_resource.Teacher.key + depends_on = [ + permitio_resource.Course, + permitio_resource.Teacher + ] +} + +# Resource Sets +resource "permitio_resource_set" "Enrolled_Course" { + name = "Enrolled Course" + key = "Enrolled_Course" + resource = permitio_resource.Course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.enrolledStudents": { + "contains": "{{user_id}}" + } + } + ] + } + ] + }) + depends_on = [permitio_resource.Course] +} + +# Role Derivations +resource "permitio_role_derivation" "Teacher_to_Course" { + role = permitio_role.Teacher.key + on_resource = permitio_resource.Course.key + to_role = permitio_role.Teacher.key + resource = permitio_resource.Course.key + linked_by = permitio_relation.Course_Teacher.key + depends_on = [ + permitio_role.Teacher, + permitio_resource.Course, + permitio_relation.Course_Teacher + ] +} + +# Condition Set Rules +resource "permitio_condition_set_rule" "student_read_enrolled_course" { + user_set = "student" + resource_set = "Enrolled_Course" + permission = "Course:read" + depends_on = [permitio_role.Student, permitio_resource_set.Enrolled_Course] +} \ No newline at end of file From 97b0b97bcfbda2d39bfe2f165688e4c3e7514e55 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:49:35 +0100 Subject: [PATCH 03/34] Add LMS app template for fine-grained authorization --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 730e7488..39b5bd37 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ coverage !templates/ !templates/*.tf !source/templates/*.tf - +!source/templates/lms-app # .tfstate files *.tfstate @@ -133,6 +133,7 @@ web_modules/ terraform_server/.env + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache From 26ce195ca5ee55f01df9801f768b8594981d11b4 Mon Sep 17 00:00:00 2001 From: Ekemini Samuel <80429893+Tabintel@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:29:31 +0000 Subject: [PATCH 04/34] addresses the comments --- .gitignore | 1 - README.md | 242 +++++++++++++++++++++------ source/templates/lms-app/README.md | 7 - source/templates/lms-app/lmsapp.tf | 189 --------------------- source/templates/lmsapp.tf | 255 +++++++++++++++++++++++++++++ 5 files changed, 445 insertions(+), 249 deletions(-) delete mode 100644 source/templates/lms-app/README.md delete mode 100644 source/templates/lms-app/lmsapp.tf create mode 100644 source/templates/lmsapp.tf diff --git a/.gitignore b/.gitignore index 39b5bd37..4d87b2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ coverage !templates/ !templates/*.tf !source/templates/*.tf -!source/templates/lms-app # .tfstate files *.tfstate diff --git a/README.md b/README.md index e6c7c152..d4fde5fb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Designed with developer experience in mind, the CLI makes it easy to integrate * - 🏗️ Automate policy operations in CI/CD with IaC and GitOps - ✨ Generate policies from natural language using AI - 🔐 Manage users, roles, and permissions directly from your terminal +- 🌍 Multi-region support for US and EU deployments > :bulb: The CLI is fully open source and is built with Pastel, using TypeScript and a React-style architecture. Contributions welcome! @@ -118,6 +119,7 @@ Below is a categorized overview of all available Permit CLI commands: - [OpenAPI -x Extensions for Policy Configuration](#openapi--x-permit-extensions-for-policy-configuration) - [`permit env apply openapi`](#permit-env-apply-openapi) - Create a full policy schema in Permit by reading an OpenAPI spec file and using `-x-permit` extensions, enabling the use of OpenAPI schema as a source of authorization policy configuration. + - [`permit env apply trino`](#permit-env-apply-trino) - Create a full policy schema in Permit by introspecting a Trino cluster's database schema, including catalogs, schemas, tables, and columns. ### [Custom Rego (OPA) and GitOps](#custom-rego-opa-and-gitops-1) @@ -145,13 +147,48 @@ The `login` command will take you to the browser to perform user authentication - `--api-key ` - store a Permit API key in your workstation keychain instead of running browser authentication - `--workspace ` - predefined workspace key to skip the workspace selection step +- `--region ` - specify the Permit region to use (`default: us`). The region determines which Permit.io API endpoints the CLI will communicate with. -**Example:** +**Examples:** + +Login with default US region: ```bash $ permit login ``` +Login with EU region: + +```bash +$ permit login --region eu +``` + +Login with API key and EU region: + +```bash +$ permit login --api-key permit_key_abc123 --region eu +``` + +**Region Support:** + +Permit.io operates in multiple regions. When you log in with a specific region, the CLI will: + +- Store your region preference in your system keychain +- Use the appropriate regional endpoints for all subsequent commands +- Generate Terraform configurations with the correct regional API URLs + +Available regions: + +- `us` (default) - United States region (`https://api.permit.io`) +- `eu` - European Union region (`https://api.eu.permit.io`) + +You can also set the region using the `PERMIT_REGION` environment variable: + +```bash +export PERMIT_REGION=eu +permit login +``` + --- #### `permit logout` @@ -386,6 +423,8 @@ Export your Permit environment configuration as a Terraform HCL file. This is useful for users who want to start working with Terraform after configuring their Permit settings through the UI or API. The command exports all environment content (resources, roles, user sets, resource sets, condition sets) in the Permit Terraform provider format. +**Note:** The export includes all roles, including default roles (admin, editor, viewer) with their actual permissions. This allows you to manage role permissions consistently across environments using Infrastructure as Code. The Terraform provider will update existing roles or create them if they don't exist. + **Arguments (Optional)** - `--api-key ` - a Permit API key to authenticate the operation. If not provided, the command will use the AuthProvider to get the API key you logged in with. @@ -411,6 +450,28 @@ Print out the output to the console - $ permit env export terraform ``` +**Region Support:** + +The generated Terraform configuration will automatically use the correct API URL based on your configured region: + +- **US region**: `api_url = "https://api.permit.io"` +- **EU region**: `api_url = "https://api.eu.permit.io"` + +The region is determined by: + +1. The `PERMIT_REGION` environment variable (if set) +2. The region stored from your last `permit login --region ` command +3. Defaults to `us` if no region is specified + +Example for EU region: + +```bash +$ export PERMIT_REGION=eu +$ permit env export terraform --file permit-eu-config.tf +``` + +This ensures that when you run `terraform apply`, the Terraform provider will communicate with the correct regional Permit.io API. + ## Fine-Grained Authorization Configuration Use natural language commands with AI to instantly set up and enforce fine-grained authorization policies. @@ -570,18 +631,99 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -#### URL Mapping +--- -After creating the policy elements based on the `-x-permit` extensions, the command will automatically create URL mappings in Permit. These mappings connect API endpoints to the appropriate resources and actions for runtime authorization checks. +### `env apply trino` -For each endpoint with the required extensions, a mapping rule will be created with: +This command introspects a Trino cluster and creates a full Permit policy schema by mapping catalogs, schemas, tables, views, materialized views, columns, functions, and procedures to Permit resources and actions. It is designed to work with any Trino-connected database (e.g., PostgreSQL, MySQL) and uses a robust passthrough strategy to discover all relevant objects. -- URL path from the OpenAPI spec -- HTTP method -- Resource from `x-permit-resource` -- Action from `x-permit-action` or the HTTP method +**Arguments (Required):** -This enables Permit to perform authorization checks directly against your API endpoints. +- `--url ` - Trino cluster URL (e.g., http://localhost:8080) +- `--user ` - Trino username + +**Arguments (Optional):** + +- `--api-key ` - API key for Permit authentication +- `--password ` - Trino password or authentication token +- `--catalog ` - Restrict to a specific catalog +- `--schema ` - Restrict to a specific schema + +**Examples:** + +Connect to Trino and sync all schemas: + +```bash +permit env apply trino --url http://localhost:8080 --user admin +``` + +Sync with authentication and a specific catalog: + +```bash +permit env apply trino --url http://localhost:8080 --user admin --password secret --catalog postgresql +``` + +Sync a specific schema with API key: + +```bash +permit env apply trino --url http://localhost:8080 --user admin --api-key permit_key --catalog postgresql --schema public +``` + +#### Resource Mapping + +- **Catalogs** → `trino-catalog-` (e.g., `trino-catalog-postgresql`) +- **Schemas** → `trino-schema--` +- **Tables** → `trino-table---` +- **Views** → `trino-view---` +- **Materialized Views** → `trino-materialized_view---` +- **Columns** → `trino-column---
-` +- **Functions** → `trino-function---` +- **Procedures** → `trino-procedure---` +- **System Resource** → `trino_sys` (Trino System resource for system-wide actions) + +Each table resource includes its columns as attributes with mapped data types: + +- `varchar`, `text` → `string` +- `integer`, `bigint`, `decimal` → `number` +- `boolean` → `bool` +- `timestamp`, `date` → `time` +- `json` → `json` +- `array` → `array` + +#### Actions + +- **Catalog actions:** `AccessCatalog`, `CreateCatalog`, `DropCatalog`, `FilterCatalogs` +- **Schema actions:** `CreateSchema`, `DropSchema`, `RenameSchema`, `SetSchemaAuthorization`, `ShowSchemas`, `FilterSchemas`, `ShowCreateSchema` +- **Table/Column actions:** `ShowCreateTable`, `CreateTable`, `DropTable`, `RenameTable`, `SetTableProperties`, `SetTableComment`, `AddColumn`, `AlterColumn`, `DropColumn`, `RenameColumn`, `SelectFromColumns`, `InsertIntoTable`, `DeleteFromTable`, `TruncateTable`, `UpdateTableColumns`, `ShowTables`, `FilterTables`, `ShowColumns`, `FilterColumns`, `SetTableAuthorization` +- **View actions:** `CreateView`, `RenameView`, `DropView`, `SetViewAuthorization`, `SetViewComment`, `CreateViewWithSelectFromColumns` +- **Materialized View actions:** `CreateMaterializedView`, `RefreshMaterializedView`, `SetMaterializedViewProperties`, `DropMaterializedView`, `RenameMaterializedView` +- **Function actions:** `ShowFunctions`, `FilterFunctions`, `ExecuteFunction`, `CreateFunction`, `DropFunction`, `ShowCreateFunction`, `CreateViewWithExecuteFunction` +- **Procedure actions:** `ExecuteProcedure`, `ExecuteTableProcedure` +- **System actions (on `trino_sys`):** + `ImpersonateUser`, `ExecuteQuery`, `ViewQueryOwnedBy`, `FilterViewQueryOwnedBy`, `KillQueryOwnedBy`, `ReadSystemInformation`, `WriteSystemInformation`, `SetSystemSessionProperty`, `GetRowFilters`, `GetColumnMask` + +#### Discovery Strategy + +- **Tables, Views, Materialized Views:** + Detected using a combination of Trino metadata, table comments, `SHOW CREATE TABLE`, and naming conventions. +- **Functions and Procedures:** + Discovered using Trino's passthrough feature (`TABLE(catalog.system.query(...))`) to query the underlying database's system tables. **Function and procedure discovery is only supported for PostgreSQL and MySQL (via passthrough); Trino UDFs and other database types are not supported.** For example, `pg_catalog.pg_proc` for PostgreSQL and `information_schema.routines` for MySQL. +- **System/Admin resources:** + By default, only user/business data resources are included. System/admin/internal catalogs and schemas are excluded unless explicitly requested. + +#### System Resource + +A special resource named `Trino System` with key `trino_sys` is always created, representing system-wide Trino actions. + +#### Testing + +Use the provided Docker Compose setup in `tests/trino/` for end-to-end testing: + +```bash +cd tests/trino +docker-compose up -d +permit env apply trino --url http://localhost:8080 --user test +``` --- @@ -923,6 +1065,34 @@ Applies a policy template to your current environment, which is useful for quick ```bash $ permit env template apply --template mesa-verde-banking-dem ``` +--- + +#### Learning management system template example +This Terraform configuration defines a role- and attribute-based access control (ABAC + RBAC) model using the Permit.io provider. It provisions resources, user attributes, user sets, and conditional access rules for a course management system. + +**Key components:** + +- Provider setup: Connects to Permit.io via api_url and api_key. + +- Resources: Defines a course resource with actions (enroll, read, create, delete) and attributes (department, studentIds, teacherId). + +- User attributes: Establishes user fields like department, id, and role. + +- User sets: Groups users into roles (admin, teacher, student) based on their attributes. + +- Resource sets: Creates logical collections of courses based on conditions such as department or enrollment. + +- Condition set rules: Links user sets and resource sets with permissions to control who can read, create, or enroll in courses. + +This configuration provides a structured example of how to model fine-grained permissions in an education-style domain using Terraform and Permit.io. + +**Usage** +1. Install the Permit CLI: `npm install -g @permitio/cli` +2. Apply the template: +```bash +$ permit env template apply --template lmsapp +``` +3. Replace `{{API_KEY}}` in `lmsapp.tf` with your Permit API key. ### API Commands @@ -1367,13 +1537,15 @@ paths: # ... ``` -A more detailed example [is available here](https://github.com/daveads/openapispec) +Check this repo for a good [example](https://github.com/daveads/openapispec) + +#### Complex Extension Objects For the more complex extensions that accept objects instead of strings, here's the expected structure: -- Object Structure: `x-permit-relation` +##### `x-permit-relation` Object Structure -``` +```json { "subject_resource": "string", // Required: The source resource in the relation "object_resource": "string", // Required: The target resource in the relation @@ -1382,9 +1554,9 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -- Object Structure: `x-permit-derived-role` +##### `x-permit-derived-role` Object Structure -``` +```json { "key": "string", // Optional: Unique identifier for the derived role "name": "string", // Optional: Human-readable name for the derived role @@ -1394,47 +1566,13 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -## Custom Rego (OPA) and GitOps - -Extend and customize authorization policies with GitOps flows and custom Rego logic. - -### Sync policies to Git repositories - -Export, version, and manage authorization policies as code: all through CLI commands - -#### `permit gitops create github` - -This command will configure your Permit environment to use the GitOps flow with GitHub. This is useful when you want to manage your policies in your own Git repository and extend them with custom policy code. - -**Arguments (Required)** - -- `--inactive ` - set the environment to inactive after configuring GitOps (`default:false`) - -**Example:** - -``` -gitops create github --inactive true -``` - --- -#### `permit gitops env clone` - -This clones the environment or the complete project from the active GitOps repository. - -**Arguments (Optional)** - -- `--api-key ` - The API key to select the project. The API Key is of the scope `Project`. -- `--dry-run` - Instead of executing the code, it displays the command to be executed. -- `--project` - Instead of selecting an environment branch to clone, it performs the standard clone operation. - -### Extend Predefined Policies with Custom Rego (Open Policy Agent) - -Use the CLI to modify and fine-tune Open Policy Agent (OPA) Rego policies while maintaining system stability. +### `opa` ---- +This collection of commands aims to create new experiences for developers working with Open Policy Agent (OPA) in their projects. -#### `permit opa policy` +### `opa policy` This command will print the available policies of an active OPA instance. This is useful when you want to see the policies in your OPA instance without fetching them from the OPA server. @@ -1517,4 +1655,4 @@ As a contributor, here are the guidelines we would like you to follow: ## There's more! - Check out [OPAL](https://github.com/permitio/OPAL) - the best way to manage Open Policy Agent (OPA), Cedar, and OpenFGA in scale. -- Check out [Cedar-Agent](https://github.com/permitio/cedar-agent), the easiest way to deploy & run AWS Cedar. +- Check out [Cedar-Agent](https://github.com/permitio/cedar-agent), the easiest way to deploy & run AWS Cedar. \ No newline at end of file diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md deleted file mode 100644 index 422c10be..00000000 --- a/source/templates/lms-app/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# LMS App Template -This template configures a Learning Management System (LMS) with resources (`course`, `enrollment`, `assignment`), roles (`student`, `teacher`, `teaching_assistant`, `admin`), and fine-grained policies (e.g., students read enrolled courses). - -## Usage -1. Install the Permit CLI: `npm install -g @permitio/cli` -2. Apply the template: `permit env template apply lms-app` -3. Replace `{{API_KEY}}` in `main.tf` with your Permit API key. \ No newline at end of file diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf deleted file mode 100644 index b8b321b5..00000000 --- a/source/templates/lms-app/lmsapp.tf +++ /dev/null @@ -1,189 +0,0 @@ -terraform { - required_providers { - permitio = { - source = "permitio/permit-io" - version = "~> 0.0.12" - } - } -} - -provider "permitio" { - api_url = "https://api.permit.io" - api_key = "{{API_KEY}}" -} - -# Resources -resource "permitio_resource" "Course" { - name = "Course" - description = "A course in the Learning Management System" - key = "Course" - - actions = { - "create" = { - name = "create" - }, - "read" = { - name = "read" - }, - "update" = { - name = "update" - }, - "delete" = { - name = "delete" - }, - "enroll" = { - name = "enroll" - } - } - attributes = { - "enrolledStudents" = { - name = "Enrolled Students" - type = "array" - }, - "teacherId" = { - name = "Teacher ID" - type = "string" - } - } -} - -resource "permitio_resource" "Enrollment" { - name = "Enrollment" - description = "Student enrollment in a course" - key = "Enrollment" - - actions = { - "create" = { - name = "create" - }, - "read" = { - name = "read" - }, - "delete" = { - name = "delete" - } - } - attributes = {} -} - -resource "permitio_resource" "Assignment" { - name = "Assignment" - description = "An assignment linked to a course" - key = "Assignment" - - actions = { - "create" = { - name = "create" - }, - "read" = { - name = "read" - }, - "update" = { - name = "update" - }, - "delete" = { - name = "delete" - }, - "grade" = { - name = "grade" - } - } - attributes = { - "dueDate" = { - name = "Due Date" - type = "string" - } - } -} - -# Roles -resource "permitio_role" "Student" { - key = "student" - name = "Student" - permissions = ["Course:read", "Enrollment:create", "Assignment:read", "Assignment:submit"] - - depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] -} - -resource "permitio_role" "Teacher" { - key = "teacher" - name = "Teacher" - permissions = ["Course:create", "Course:read", "Course:update", "Course:delete", "Assignment:read", "Assignment:grade"] - - depends_on = [permitio_resource.Course, permitio_resource.Assignment] -} - -resource "permitio_role" "Teaching_Assistant" { - key = "teaching_assistant" - name = "Teaching Assistant" - permissions = ["Course:read", "Course:update", "Assignment:read"] - - depends_on = [permitio_resource.Course, permitio_resource.Assignment] -} - -resource "permitio_role" "Admin" { - key = "admin" - name = "Admin" - permissions = [ - "Course:create", "Course:read", "Course:update", "Course:delete", - "Enrollment:create", "Enrollment:read", "Enrollment:delete", - "Assignment:create", "Assignment:read", "Assignment:update", "Assignment:delete", "Assignment:grade" - ] - - depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] -} - -# Relations -resource "permitio_relation" "Course_Teacher" { - key = "assigned_to" - name = "Assigned To" - subject_resource = permitio_resource.Course.key - object_resource = permitio_resource.Teacher.key - depends_on = [ - permitio_resource.Course, - permitio_resource.Teacher - ] -} - -# Resource Sets -resource "permitio_resource_set" "Enrolled_Course" { - name = "Enrolled Course" - key = "Enrolled_Course" - resource = permitio_resource.Course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.enrolledStudents": { - "contains": "{{user_id}}" - } - } - ] - } - ] - }) - depends_on = [permitio_resource.Course] -} - -# Role Derivations -resource "permitio_role_derivation" "Teacher_to_Course" { - role = permitio_role.Teacher.key - on_resource = permitio_resource.Course.key - to_role = permitio_role.Teacher.key - resource = permitio_resource.Course.key - linked_by = permitio_relation.Course_Teacher.key - depends_on = [ - permitio_role.Teacher, - permitio_resource.Course, - permitio_relation.Course_Teacher - ] -} - -# Condition Set Rules -resource "permitio_condition_set_rule" "student_read_enrolled_course" { - user_set = "student" - resource_set = "Enrolled_Course" - permission = "Course:read" - depends_on = [permitio_role.Student, permitio_resource_set.Enrolled_Course] -} \ No newline at end of file diff --git a/source/templates/lmsapp.tf b/source/templates/lmsapp.tf new file mode 100644 index 00000000..a4c0ef6f --- /dev/null +++ b/source/templates/lmsapp.tf @@ -0,0 +1,255 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.14" + } + } +} + +provider "permitio" { + api_url = {{API_URL}} + api_key = {{API_KEY}} +} + +# Resources +resource "permitio_resource" "course" { + name = "course" + description = "" + key = "course" + + actions = { + "enroll" = { + name = "enroll" + }, + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "delete" = { + name = "delete" + } + } + attributes = { + "department" = { + name = "Department" + type = "string" + }, + "studentIds" = { + name = "Student Ids" + type = "array" + }, + "teacherId" = { + name = "Teacher Id" + type = "string" + } + } +} + +# User Attributes +resource "permitio_user_attribute" "user_department" { + key = "department" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_id" { + key = "id" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_role" { + key = "role" + type = "string" + description = "user role" +} + +# Roles + +# Condition Set Rules +resource "permitio_condition_set_rule" "student_Courses_Where_Student_is_Enrolled_and_Same_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department, + permitio_user_set.student + ] +} +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.teacher.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher + ] +} +resource "permitio_condition_set_rule" "student_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.student + ] +} +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_create" { + user_set = permitio_user_set.teacher.key + permission = "course:create" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher + ] +} +resource "permitio_condition_set_rule" "student_Courses_Where_Student_Can_Enroll_course_enroll" { + user_set = permitio_user_set.student.key + permission = "course:enroll" + resource_set = permitio_resource_set.Courses_Where_Student_Can_Enroll.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_Can_Enroll, + permitio_user_set.student + ] +} + +# Resource Sets +resource "permitio_resource_set" "Courses_Where_Student_Can_Enroll" { + name = "Courses Where Student Can Enroll" + key = "Courses_Where_Student_Can_Enroll" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Where_Student_is_Enrolled_and_Same_Department" { + name = "Courses Where Student is Enrolled and Same Department" + key = "Courses_Where_Student_is_Enrolled_and_Same_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + }, + { + "resource.studentIds": { + "array_contains": { + "ref": "user.id" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Matching_Teacher_Department" { + name = "Courses Matching Teacher Department" + key = "Courses_Matching_Teacher_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} + +# User Sets +resource "permitio_user_set" "admin" { + key = "admin" + name = "admin" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "admin" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} +resource "permitio_user_set" "student" { + key = "student" + name = "student" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "student" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} +resource "permitio_user_set" "teacher" { + key = "teacher" + name = "teacher" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "teacher" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} \ No newline at end of file From 5f9c37a264e32a0afafeb868d28e14c9207521e0 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:27:54 +0100 Subject: [PATCH 05/34] Add LMS app template for fine-grained authorization --- source/templates/lms-app/README.md | 0 source/templates/lms-app/lmsapp.tf | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 source/templates/lms-app/README.md create mode 100644 source/templates/lms-app/lmsapp.tf diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md new file mode 100644 index 00000000..e69de29b diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf new file mode 100644 index 00000000..e69de29b From f2c036095e6f61967badcbabbc0cd66a5bc376fe Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:44:32 +0100 Subject: [PATCH 06/34] Add LMS app template for fine-grained authorization --- source/templates/lms-app/README.md | 7 ++ source/templates/lms-app/lmsapp.tf | 189 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md index e69de29b..422c10be 100644 --- a/source/templates/lms-app/README.md +++ b/source/templates/lms-app/README.md @@ -0,0 +1,7 @@ +# LMS App Template +This template configures a Learning Management System (LMS) with resources (`course`, `enrollment`, `assignment`), roles (`student`, `teacher`, `teaching_assistant`, `admin`), and fine-grained policies (e.g., students read enrolled courses). + +## Usage +1. Install the Permit CLI: `npm install -g @permitio/cli` +2. Apply the template: `permit env template apply lms-app` +3. Replace `{{API_KEY}}` in `main.tf` with your Permit API key. \ No newline at end of file diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf index e69de29b..b8b321b5 100644 --- a/source/templates/lms-app/lmsapp.tf +++ b/source/templates/lms-app/lmsapp.tf @@ -0,0 +1,189 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.12" + } + } +} + +provider "permitio" { + api_url = "https://api.permit.io" + api_key = "{{API_KEY}}" +} + +# Resources +resource "permitio_resource" "Course" { + name = "Course" + description = "A course in the Learning Management System" + key = "Course" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + }, + "enroll" = { + name = "enroll" + } + } + attributes = { + "enrolledStudents" = { + name = "Enrolled Students" + type = "array" + }, + "teacherId" = { + name = "Teacher ID" + type = "string" + } + } +} + +resource "permitio_resource" "Enrollment" { + name = "Enrollment" + description = "Student enrollment in a course" + key = "Enrollment" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "delete" = { + name = "delete" + } + } + attributes = {} +} + +resource "permitio_resource" "Assignment" { + name = "Assignment" + description = "An assignment linked to a course" + key = "Assignment" + + actions = { + "create" = { + name = "create" + }, + "read" = { + name = "read" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + }, + "grade" = { + name = "grade" + } + } + attributes = { + "dueDate" = { + name = "Due Date" + type = "string" + } + } +} + +# Roles +resource "permitio_role" "Student" { + key = "student" + name = "Student" + permissions = ["Course:read", "Enrollment:create", "Assignment:read", "Assignment:submit"] + + depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] +} + +resource "permitio_role" "Teacher" { + key = "teacher" + name = "Teacher" + permissions = ["Course:create", "Course:read", "Course:update", "Course:delete", "Assignment:read", "Assignment:grade"] + + depends_on = [permitio_resource.Course, permitio_resource.Assignment] +} + +resource "permitio_role" "Teaching_Assistant" { + key = "teaching_assistant" + name = "Teaching Assistant" + permissions = ["Course:read", "Course:update", "Assignment:read"] + + depends_on = [permitio_resource.Course, permitio_resource.Assignment] +} + +resource "permitio_role" "Admin" { + key = "admin" + name = "Admin" + permissions = [ + "Course:create", "Course:read", "Course:update", "Course:delete", + "Enrollment:create", "Enrollment:read", "Enrollment:delete", + "Assignment:create", "Assignment:read", "Assignment:update", "Assignment:delete", "Assignment:grade" + ] + + depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] +} + +# Relations +resource "permitio_relation" "Course_Teacher" { + key = "assigned_to" + name = "Assigned To" + subject_resource = permitio_resource.Course.key + object_resource = permitio_resource.Teacher.key + depends_on = [ + permitio_resource.Course, + permitio_resource.Teacher + ] +} + +# Resource Sets +resource "permitio_resource_set" "Enrolled_Course" { + name = "Enrolled Course" + key = "Enrolled_Course" + resource = permitio_resource.Course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.enrolledStudents": { + "contains": "{{user_id}}" + } + } + ] + } + ] + }) + depends_on = [permitio_resource.Course] +} + +# Role Derivations +resource "permitio_role_derivation" "Teacher_to_Course" { + role = permitio_role.Teacher.key + on_resource = permitio_resource.Course.key + to_role = permitio_role.Teacher.key + resource = permitio_resource.Course.key + linked_by = permitio_relation.Course_Teacher.key + depends_on = [ + permitio_role.Teacher, + permitio_resource.Course, + permitio_relation.Course_Teacher + ] +} + +# Condition Set Rules +resource "permitio_condition_set_rule" "student_read_enrolled_course" { + user_set = "student" + resource_set = "Enrolled_Course" + permission = "Course:read" + depends_on = [permitio_role.Student, permitio_resource_set.Enrolled_Course] +} \ No newline at end of file From 22dd9a339931f9ec6b8c48977b4741701a7c14e6 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Mon, 14 Jul 2025 17:49:35 +0100 Subject: [PATCH 07/34] Add LMS app template for fine-grained authorization --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 730e7488..39b5bd37 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ coverage !templates/ !templates/*.tf !source/templates/*.tf - +!source/templates/lms-app # .tfstate files *.tfstate @@ -133,6 +133,7 @@ web_modules/ terraform_server/.env + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache From 58097967610b1dee4d68ec66508c94dc13ecdd70 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Fri, 5 Dec 2025 15:07:30 +0100 Subject: [PATCH 08/34] Add LMS app template configuration --- source/templates/lms-app/lmsapp.tf | 336 +++++++++++++++++------------ 1 file changed, 201 insertions(+), 135 deletions(-) diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf index b8b321b5..a4c0ef6f 100644 --- a/source/templates/lms-app/lmsapp.tf +++ b/source/templates/lms-app/lmsapp.tf @@ -1,189 +1,255 @@ terraform { required_providers { permitio = { - source = "permitio/permit-io" - version = "~> 0.0.12" + source = "permitio/permit-io" + version = "~> 0.0.14" } } } provider "permitio" { - api_url = "https://api.permit.io" - api_key = "{{API_KEY}}" + api_url = {{API_URL}} + api_key = {{API_KEY}} } # Resources -resource "permitio_resource" "Course" { - name = "Course" - description = "A course in the Learning Management System" - key = "Course" +resource "permitio_resource" "course" { + name = "course" + description = "" + key = "course" actions = { - "create" = { - name = "create" + "enroll" = { + name = "enroll" }, "read" = { name = "read" }, - "update" = { - name = "update" + "create" = { + name = "create" }, "delete" = { name = "delete" - }, - "enroll" = { - name = "enroll" } } attributes = { - "enrolledStudents" = { - name = "Enrolled Students" + "department" = { + name = "Department" + type = "string" + }, + "studentIds" = { + name = "Student Ids" type = "array" }, "teacherId" = { - name = "Teacher ID" + name = "Teacher Id" type = "string" } } } -resource "permitio_resource" "Enrollment" { - name = "Enrollment" - description = "Student enrollment in a course" - key = "Enrollment" - - actions = { - "create" = { - name = "create" - }, - "read" = { - name = "read" - }, - "delete" = { - name = "delete" - } - } - attributes = {} +# User Attributes +resource "permitio_user_attribute" "user_department" { + key = "department" + type = "string" + description = "" } - -resource "permitio_resource" "Assignment" { - name = "Assignment" - description = "An assignment linked to a course" - key = "Assignment" - - actions = { - "create" = { - name = "create" - }, - "read" = { - name = "read" - }, - "update" = { - name = "update" - }, - "delete" = { - name = "delete" - }, - "grade" = { - name = "grade" - } - } - attributes = { - "dueDate" = { - name = "Due Date" - type = "string" - } - } +resource "permitio_user_attribute" "user_id" { + key = "id" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_role" { + key = "role" + type = "string" + description = "user role" } # Roles -resource "permitio_role" "Student" { - key = "student" - name = "Student" - permissions = ["Course:read", "Enrollment:create", "Assignment:read", "Assignment:submit"] - depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] +# Condition Set Rules +resource "permitio_condition_set_rule" "student_Courses_Where_Student_is_Enrolled_and_Same_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department, + permitio_user_set.student + ] } - -resource "permitio_role" "Teacher" { - key = "teacher" - name = "Teacher" - permissions = ["Course:create", "Course:read", "Course:update", "Course:delete", "Assignment:read", "Assignment:grade"] - - depends_on = [permitio_resource.Course, permitio_resource.Assignment] +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.teacher.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher + ] } - -resource "permitio_role" "Teaching_Assistant" { - key = "teaching_assistant" - name = "Teaching Assistant" - permissions = ["Course:read", "Course:update", "Assignment:read"] - - depends_on = [permitio_resource.Course, permitio_resource.Assignment] +resource "permitio_condition_set_rule" "student_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.student + ] } - -resource "permitio_role" "Admin" { - key = "admin" - name = "Admin" - permissions = [ - "Course:create", "Course:read", "Course:update", "Course:delete", - "Enrollment:create", "Enrollment:read", "Enrollment:delete", - "Assignment:create", "Assignment:read", "Assignment:update", "Assignment:delete", "Assignment:grade" +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_create" { + user_set = permitio_user_set.teacher.key + permission = "course:create" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher ] - - depends_on = [permitio_resource.Course, permitio_resource.Enrollment, permitio_resource.Assignment] } - -# Relations -resource "permitio_relation" "Course_Teacher" { - key = "assigned_to" - name = "Assigned To" - subject_resource = permitio_resource.Course.key - object_resource = permitio_resource.Teacher.key - depends_on = [ - permitio_resource.Course, - permitio_resource.Teacher +resource "permitio_condition_set_rule" "student_Courses_Where_Student_Can_Enroll_course_enroll" { + user_set = permitio_user_set.student.key + permission = "course:enroll" + resource_set = permitio_resource_set.Courses_Where_Student_Can_Enroll.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_Can_Enroll, + permitio_user_set.student ] } # Resource Sets -resource "permitio_resource_set" "Enrolled_Course" { - name = "Enrolled Course" - key = "Enrolled_Course" - resource = permitio_resource.Course.key +resource "permitio_resource_set" "Courses_Where_Student_Can_Enroll" { + name = "Courses Where Student Can Enroll" + key = "Courses_Where_Student_Can_Enroll" + resource = permitio_resource.course.key conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.enrolledStudents": { - "contains": "{{user_id}}" + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" } } - ] - } - ] - }) - depends_on = [permitio_resource.Course] + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Where_Student_is_Enrolled_and_Same_Department" { + name = "Courses Where Student is Enrolled and Same Department" + key = "Courses_Where_Student_is_Enrolled_and_Same_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + }, + { + "resource.studentIds": { + "array_contains": { + "ref": "user.id" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Matching_Teacher_Department" { + name = "Courses Matching Teacher Department" + key = "Courses_Matching_Teacher_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] } -# Role Derivations -resource "permitio_role_derivation" "Teacher_to_Course" { - role = permitio_role.Teacher.key - on_resource = permitio_resource.Course.key - to_role = permitio_role.Teacher.key - resource = permitio_resource.Course.key - linked_by = permitio_relation.Course_Teacher.key +# User Sets +resource "permitio_user_set" "admin" { + key = "admin" + name = "admin" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "admin" + } + } + ] + } + ] +}) depends_on = [ - permitio_role.Teacher, - permitio_resource.Course, - permitio_relation.Course_Teacher + permitio_user_attribute.user_role ] } - -# Condition Set Rules -resource "permitio_condition_set_rule" "student_read_enrolled_course" { - user_set = "student" - resource_set = "Enrolled_Course" - permission = "Course:read" - depends_on = [permitio_role.Student, permitio_resource_set.Enrolled_Course] +resource "permitio_user_set" "student" { + key = "student" + name = "student" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "student" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} +resource "permitio_user_set" "teacher" { + key = "teacher" + name = "teacher" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "teacher" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] } \ No newline at end of file From 24384dc11809899dc9c000859148005697b6a6f9 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Fri, 5 Dec 2025 15:13:04 +0100 Subject: [PATCH 09/34] Update .gitignore and remove unused README --- .gitignore | 5 ++--- source/templates/lms-app/README.md | 7 ------- 2 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 source/templates/lms-app/README.md diff --git a/.gitignore b/.gitignore index 39b5bd37..eff94491 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ coverage !templates/ !templates/*.tf !source/templates/*.tf -!source/templates/lms-app + # .tfstate files *.tfstate @@ -133,7 +133,6 @@ web_modules/ terraform_server/.env - # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache @@ -188,4 +187,4 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* +.pnp.* \ No newline at end of file diff --git a/source/templates/lms-app/README.md b/source/templates/lms-app/README.md deleted file mode 100644 index 422c10be..00000000 --- a/source/templates/lms-app/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# LMS App Template -This template configures a Learning Management System (LMS) with resources (`course`, `enrollment`, `assignment`), roles (`student`, `teacher`, `teaching_assistant`, `admin`), and fine-grained policies (e.g., students read enrolled courses). - -## Usage -1. Install the Permit CLI: `npm install -g @permitio/cli` -2. Apply the template: `permit env template apply lms-app` -3. Replace `{{API_KEY}}` in `main.tf` with your Permit API key. \ No newline at end of file From 24bb16efec56b853ffc3e225fac1aa618b4eecd8 Mon Sep 17 00:00:00 2001 From: Gabriel Manor Date: Tue, 22 Jul 2025 23:25:32 +0300 Subject: [PATCH 10/34] Trino Schema Command --- source/commands/env/apply/trino.tsx | 67 ++ .../components/env/trino/TrinoComponent.tsx | 100 +++ source/components/env/trino/types.ts | 29 + source/hooks/trino/useTrinoProcessor.ts | 51 ++ source/hooks/useResourcesApi.ts | 54 +- source/utils/trinoUtils.ts | 762 ++++++++++++++++++ tests/cli.test.tsx | 11 +- .../env/trino/TrinoComponent.test.tsx | 112 +++ tests/env/apply/trino.test.tsx | 42 + tests/hooks/trino/useTrinoProcessor.test.tsx | 358 ++++++++ tests/trino/README.md | 93 +++ tests/trino/docker-compose.yml | 65 ++ tests/trino/sample_data/mysql_init.sql | 96 +++ tests/trino/sample_data/postgres_init.sql | 94 +++ .../trino_config/catalog/mysql.properties | 4 + .../catalog/postgresql.properties | 4 + tests/trino/trino_config/config.properties | 4 + tests/trino/trino_config/jvm.config | 7 + tests/trino/trino_config/node.properties | 3 + tests/utils/trinoUtils.test.tsx | 168 ++++ 20 files changed, 2101 insertions(+), 23 deletions(-) create mode 100644 source/commands/env/apply/trino.tsx create mode 100644 source/components/env/trino/TrinoComponent.tsx create mode 100644 source/components/env/trino/types.ts create mode 100644 source/hooks/trino/useTrinoProcessor.ts create mode 100644 source/utils/trinoUtils.ts create mode 100644 tests/components/env/trino/TrinoComponent.test.tsx create mode 100644 tests/env/apply/trino.test.tsx create mode 100644 tests/hooks/trino/useTrinoProcessor.test.tsx create mode 100644 tests/trino/README.md create mode 100644 tests/trino/docker-compose.yml create mode 100644 tests/trino/sample_data/mysql_init.sql create mode 100644 tests/trino/sample_data/postgres_init.sql create mode 100644 tests/trino/trino_config/catalog/mysql.properties create mode 100644 tests/trino/trino_config/catalog/postgresql.properties create mode 100644 tests/trino/trino_config/config.properties create mode 100644 tests/trino/trino_config/jvm.config create mode 100644 tests/trino/trino_config/node.properties create mode 100644 tests/utils/trinoUtils.test.tsx diff --git a/source/commands/env/apply/trino.tsx b/source/commands/env/apply/trino.tsx new file mode 100644 index 00000000..ea1d9db6 --- /dev/null +++ b/source/commands/env/apply/trino.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { option } from 'pastel'; +import zod from 'zod'; +import { AuthProvider } from '../../../components/AuthProvider.js'; +import TrinoComponent from '../../../components/env/trino/TrinoComponent.js'; +import type { TrinoOptions } from '../../../components/env/trino/types.js'; + +export const description = + 'Apply permissions policy from a Trino schema, creating resources from catalogs, schemas, tables, columns.'; + +export const options = zod.object({ + apiKey: zod + .string() + .optional() + .describe( + option({ + description: 'API key for Permit authentication', + alias: 'k', + }), + ), + url: zod.string().describe( + option({ + description: 'Trino cluster URL (e.g., http://localhost:8080)', + alias: 'u', + }), + ), + user: zod.string().describe( + option({ + description: 'Trino username', + }), + ), + password: zod + .string() + .optional() + .describe( + option({ + description: 'Trino password or authentication token', + alias: 'p', + }), + ), + catalog: zod + .string() + .optional() + .describe( + option({ + description: 'Restrict to a specific catalog', + alias: 'c', + }), + ), + schema: zod + .string() + .optional() + .describe( + option({ + description: 'Restrict to a specific schema', + alias: 's', + }), + ), +}); + +export default function Trino({ options }: { options: TrinoOptions }) { + return ( + + + + ); +} diff --git a/source/components/env/trino/TrinoComponent.tsx b/source/components/env/trino/TrinoComponent.tsx new file mode 100644 index 00000000..7b7b2e41 --- /dev/null +++ b/source/components/env/trino/TrinoComponent.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'ink'; +import type { TrinoOptions, PermitResource } from './types.js'; +import { useTrinoProcessor } from '../../../hooks/trino/useTrinoProcessor.js'; +import { mapTrinoSchemaToPermitResources } from '../../../utils/trinoUtils.js'; + +export default function TrinoComponent( + props: TrinoOptions, +): React.ReactElement { + const { processTrinoSchema, status, errorMessage } = useTrinoProcessor(); + const [createdResources, setCreatedResources] = useState( + [], + ); + + useEffect(() => { + (async () => { + const client = await import('../../../utils/trinoUtils.js'); + const { connectToTrino, fetchTrinoSchema } = client; + const trinoClient = connectToTrino(props); + const trinoSchema = await fetchTrinoSchema(trinoClient, { + catalog: props.catalog, + schema: props.schema, + }); + const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); + setCreatedResources(permitResources); + await processTrinoSchema(props); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (status === 'processing') { + return Processing Trino schema and syncing with Permit...; + } + if (status === 'error') { + return Error: {errorMessage}; + } + if (status === 'done') { + // Group resources by type + const grouped: Record = {}; + for (const r of createdResources) { + let type = ''; + if (r.key === 'trino_sys') { + type = 'System'; + } else { + const match = r.key.match(/^trino-([a-z_]+)/); + if (match && match[1]) { + type = match[1] + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } else { + type = 'Other'; + } + } + if (!grouped[type]) { + grouped[type] = []; + } + const arr = grouped[type]!; + arr.push(r.name); + } + // Sort types in a preferred order + const typeOrder = [ + 'Catalog', + 'Schema', + 'Table', + 'View', + 'Materialized View', + 'Column', + 'Function', + 'Procedure', + 'System', + 'Other', + ]; + const sortedTypes = Object.keys(grouped).sort((a, b) => { + const ia = typeOrder.indexOf(a); + const ib = typeOrder.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + return ( + <> + Trino schema successfully synced with Permit! + {sortedTypes.map(type => { + const items = grouped[type] ?? []; + return ( + + {type + 's'} ({items.length}) + {items + .sort((a, b) => a.localeCompare(b)) + .map(name => `\n - ${name}`) + .join('')} + + ); + })} + + ); + } + return Ready to process Trino schema...; +} diff --git a/source/components/env/trino/types.ts b/source/components/env/trino/types.ts new file mode 100644 index 00000000..413099dc --- /dev/null +++ b/source/components/env/trino/types.ts @@ -0,0 +1,29 @@ +export type TrinoOptions = { + apiKey?: string; + url: string; + user: string; + password?: string; + catalog?: string; + schema?: string; +}; + +export interface PermitResource { + key: string; + name: string; + description?: string; + actions: string[]; + attributes?: { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }; +} diff --git a/source/hooks/trino/useTrinoProcessor.ts b/source/hooks/trino/useTrinoProcessor.ts new file mode 100644 index 00000000..5b3da903 --- /dev/null +++ b/source/hooks/trino/useTrinoProcessor.ts @@ -0,0 +1,51 @@ +/** + * Hook for processing Trino schema extraction and mapping to Permit resources. + * Lint/prettier compliant, strict types, ready for implementation. + */ + +import { useCallback } from 'react'; +import { + connectToTrino, + fetchTrinoSchema, + mapTrinoSchemaToPermitResources, + TrinoSchemaData, +} from '../../utils/trinoUtils.js'; +import { useResourcesApi } from '../useResourcesApi.js'; +import type { TrinoOptions } from '../../components/env/trino/types.js'; + +export function useTrinoProcessor() { + const { createBulkResources, status, errorMessage } = useResourcesApi(); + + /** + * Main processing function. + * Connects to Trino, extracts schema, maps to Permit resources, and syncs with Permit. + */ + const processTrinoSchema = useCallback( + async (options: TrinoOptions): Promise => { + // 1. Connect to Trino + const client = connectToTrino(options); + + // 2. Fetch Trino schema + const trinoSchema: TrinoSchemaData = await fetchTrinoSchema(client, { + catalog: options.catalog, + schema: options.schema, + }); + + // 3. Map to Permit resources + const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); + + // 4. Sync with Permit (omit 'type' property, ensure actions is an object) + await createBulkResources( + permitResources.map(({ actions, ...r }) => ({ + ...r, + actions: Object.fromEntries( + actions.map((action: string) => [action, {}]), + ), + })), + ); + }, + [createBulkResources], + ); + + return { processTrinoSchema, status, errorMessage }; +} diff --git a/source/hooks/useResourcesApi.ts b/source/hooks/useResourcesApi.ts index 98c0978d..cb592983 100644 --- a/source/hooks/useResourcesApi.ts +++ b/source/hooks/useResourcesApi.ts @@ -20,32 +20,42 @@ export const useResourcesApi = () => { const getExistingResources = useCallback(async () => { try { const client = authenticatedApiClient(); - const result = await client.GET( - `/v2/schema/{proj_id}/{env_id}/resources`, - ); - const error = result.error; - - if (error) throw new Error(error); - if (!result.data) { - setErrorMessage('No resources found'); - return new Set(); - } + let allResources: { key: string }[] = []; + let page = 1; + const perPage = 100; + while (true) { + const result = await client.GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + undefined, + undefined, + { page, per_page: perPage }, + ); + const error = result.error; - type Resource = { key: string }; + if (error) throw new Error(error); + if (!result.data) { + setErrorMessage('No resources found'); + break; + } - const raw = result.data; - let resources: Resource[] = []; + let resources: { key: string }[] = []; + const raw = result.data; + if (Array.isArray(raw)) { + resources = raw; + } else if (raw && Array.isArray(raw.data)) { + resources = raw.data; + } else { + setErrorMessage('Invalid resource data format'); + break; + } - if (Array.isArray(raw)) { - resources = raw; - } else if (raw && Array.isArray(raw.data)) { - resources = raw.data; - } else { - setErrorMessage('Invalid resource data format'); - return new Set(); + allResources = allResources.concat(resources); + if (resources.length < perPage) { + break; + } + page++; } - - return new Set(resources.map(r => r.key)); + return new Set(allResources.map(r => r.key)); } catch (error) { setErrorMessage((error as Error).message); return new Set(); diff --git a/source/utils/trinoUtils.ts b/source/utils/trinoUtils.ts new file mode 100644 index 00000000..6a951e4c --- /dev/null +++ b/source/utils/trinoUtils.ts @@ -0,0 +1,762 @@ +/** + * Utility functions for connecting to Trino and extracting schema information. + * All functions are lint/prettier compliant and ready for implementation. + */ + +import type { PermitResource } from '../components/env/trino/types.js'; + +export interface TrinoColumn { + name: string; + type: string; + nullable: boolean; +} + +export interface TrinoTable { + catalog: string; + schema: string; + name: string; + type: string; + columns: TrinoColumn[]; +} + +export interface TrinoSchema { + catalog: string; + name: string; +} + +export interface TrinoCatalog { + name: string; +} + +export interface TrinoFunction { + catalog: string; + schema: string; + name: string; + returnType: string; + argumentTypes: string[]; +} + +export interface TrinoView { + catalog: string; + schema: string; + name: string; + columns: TrinoColumn[]; +} + +export interface TrinoMaterializedView { + catalog: string; + schema: string; + name: string; + columns: TrinoColumn[]; +} + +export interface TrinoProcedure { + catalog: string; + schema: string; + name: string; + argumentTypes: string[]; +} + +export interface TrinoSchemaData { + catalogs: TrinoCatalog[]; + schemas: TrinoSchema[]; + tables: TrinoTable[]; + functions: TrinoFunction[]; + views: TrinoView[]; + materializedViews: TrinoMaterializedView[]; + procedures: TrinoProcedure[]; +} + +/** + * Map Trino column type to Permit attribute type. + */ +export function trinoTypeToPermitType( + trinoType: string, +): + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array' { + const t = trinoType.toLowerCase(); + if (t.includes('char') || t === 'uuid' || t === 'varchar') return 'string'; + if (t === 'boolean') return 'bool'; + if ( + t === 'integer' || + t === 'int' || + t === 'bigint' || + t === 'smallint' || + t === 'tinyint' || + t === 'double' || + t === 'real' || + t === 'float' || + t === 'decimal' + ) + return 'number'; + if (t === 'json') return 'json'; + if (t === 'array') return 'array'; + if (t === 'object' || t === 'row') return 'object'; + if (t === 'timestamp' || t === 'date' || t === 'time') return 'time'; + return 'string'; +} + +/** + * Map Trino schema data to Permit resources. + * - Each catalog, schema, table, and column is a resource. + * - Each table resource includes columns as attributes (with type/description). + */ +export function mapTrinoSchemaToPermitResources( + trino: TrinoSchemaData, +): PermitResource[] { + const resources: PermitResource[] = []; + const SEP = '-'; + + // Catalogs + for (const catalog of trino.catalogs) { + resources.push({ + key: `trino${SEP}catalog${SEP}${catalog.name}`, + name: catalog.name, + description: `Trino resource type: catalog. Trino catalog: ${catalog.name}`, + actions: [ + 'AccessCatalog', + 'CreateCatalog', + 'DropCatalog', + 'FilterCatalogs', + ], + }); + } + + // Schemas + for (const schema of trino.schemas) { + resources.push({ + key: `trino${SEP}schema${SEP}${schema.catalog}${SEP}${schema.name}`, + name: `${schema.catalog}.${schema.name}`, + description: `Trino resource type: schema. Schema ${schema.name} in catalog ${schema.catalog}`, + actions: [ + 'CreateSchema', + 'DropSchema', + 'RenameSchema', + 'SetSchemaAuthorization', + 'ShowSchemas', + 'FilterSchemas', + 'ShowCreateSchema', + ], + }); + } + + const TABLE_AND_COLUMN_ACTIONS = [ + 'ShowCreateTable', + 'CreateTable', + 'DropTable', + 'RenameTable', + 'SetTableProperties', + 'SetTableComment', + 'AddColumn', + 'AlterColumn', + 'DropColumn', + 'RenameColumn', + 'SelectFromColumns', + 'InsertIntoTable', + 'DeleteFromTable', + 'TruncateTable', + 'UpdateTableColumns', + 'ShowTables', + 'FilterTables', + 'ShowColumns', + 'FilterColumns', + 'SetTableAuthorization', + ]; + + // Tables and columns + for (const table of trino.tables) { + const tableKey = `trino${SEP}table${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}`; + resources.push({ + key: tableKey, + name: `${table.catalog}.${table.schema}.${table.name}`, + description: `Trino resource type: ${table.type.toLowerCase()}. ${table.type} ${table.name} in ${table.catalog}.${table.schema}`, + actions: TABLE_AND_COLUMN_ACTIONS, + attributes: table.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + // Columns as resources + for (const column of table.columns) { + resources.push({ + key: `trino${SEP}column${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}${SEP}${column.name}`, + name: `${table.catalog}.${table.schema}.${table.name}.${column.name}`, + description: `Trino resource type: column. Column ${column.name} in ${table.catalog}.${table.schema}.${table.name}`, + actions: TABLE_AND_COLUMN_ACTIONS, + attributes: { + parent_table: { + type: 'string', + description: `${table.catalog}.${table.schema}.${table.name}`, + }, + table_type: { type: 'string', description: table.type.toLowerCase() }, + type: { + type: trinoTypeToPermitType(column.type), + description: column.type, + }, + nullable: { + type: 'bool', + description: column.nullable ? 'nullable' : undefined, + }, + }, + }); + } + } + + // Views + for (const view of trino.views) { + resources.push({ + key: `trino${SEP}view${SEP}${view.catalog}${SEP}${view.schema}${SEP}${view.name}`, + name: `${view.catalog}.${view.schema}.${view.name}`, + description: `Trino resource type: view. View ${view.name} in ${view.catalog}.${view.schema}`, + actions: [ + 'CreateView', + 'RenameView', + 'DropView', + 'SetViewAuthorization', + 'SetViewComment', + 'CreateViewWithSelectFromColumns', + ], + attributes: view.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + } + + // Materialized Views + for (const mview of trino.materializedViews) { + resources.push({ + key: `trino${SEP}materialized_view${SEP}${mview.catalog}${SEP}${mview.schema}${SEP}${mview.name}`, + name: `${mview.catalog}.${mview.schema}.${mview.name}`, + description: `Trino resource type: materialized view. Materialized view ${mview.name} in ${mview.catalog}.${mview.schema}`, + actions: [ + 'CreateMaterializedView', + 'RefreshMaterializedView', + 'SetMaterializedViewProperties', + 'DropMaterializedView', + 'RenameMaterializedView', + ], + attributes: mview.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + } + + // Functions + for (const fn of trino.functions) { + resources.push({ + key: `trino${SEP}function${SEP}${fn.catalog}${SEP}${fn.schema}${SEP}${fn.name}`, + name: `${fn.catalog}.${fn.schema}.${fn.name}`, + description: `Trino resource type: function. Function ${fn.name} in ${fn.catalog}.${fn.schema}`, + actions: [ + 'ShowFunctions', + 'FilterFunctions', + 'ExecuteFunction', + 'CreateFunction', + 'DropFunction', + 'ShowCreateFunction', + 'CreateViewWithExecuteFunction', + ], + attributes: { + returnType: { type: trinoTypeToPermitType(fn.returnType) }, + argumentTypes: { type: 'array' }, + }, + }); + } + + // Procedures + for (const proc of trino.procedures) { + resources.push({ + key: `trino${SEP}procedure${SEP}${proc.catalog}${SEP}${proc.schema}${SEP}${proc.name}`, + name: `${proc.catalog}.${proc.schema}.${proc.name}`, + description: `Trino resource type: procedure. Procedure ${proc.name} in ${proc.catalog}.${proc.schema}`, + actions: ['ExecuteProcedure', 'ExecuteTableProcedure'], + attributes: { + argumentTypes: { type: 'array' }, + }, + }); + } + + // Add Trino System resource + resources.push({ + key: 'trino_sys', + name: 'Trino System', + description: 'Trino system-level resource for system-wide actions.', + actions: [ + 'ImpersonateUser', + 'ExecuteQuery', + 'ViewQueryOwnedBy', + 'FilterViewQueryOwnedBy', + 'KillQueryOwnedBy', + 'ReadSystemInformation', + 'WriteSystemInformation', + 'SetSystemSessionProperty', + 'GetRowFilters', + 'GetColumnMask', + ], + }); + + return resources; +} + +// Connect to a Trino cluster (returns a client config) +export function connectToTrino(options: { + url: string; + user: string; + password?: string; +}): { baseUrl: string; headers: Record } { + const headers: Record = { + 'X-Trino-User': options.user, + 'X-Trino-Source': 'permit-cli', + }; + if (options.password) { + // Use btoa for base64 encoding + headers['Authorization'] = + 'Basic ' + btoa(`${options.user}:${options.password}`); + } + return { + baseUrl: options.url.replace(/\/$/, ''), + headers, + }; +} + +// Helper to execute a Trino query and return all rows +async function executeTrinoQuery( + client: { baseUrl: string; headers: Record }, + query: string, +): Promise { + const res = await fetch(`${client.baseUrl}/v1/statement`, { + method: 'POST', + headers: { + ...client.headers, + 'Content-Type': 'text/plain', + }, + body: query, + }); + if (!res.ok) + throw new Error(`Trino query failed: ${res.status} ${res.statusText}`); + let data = await res.json(); + let rows: string[][] = data.data || []; + let nextUri = data.nextUri; + while (nextUri) { + const nextRes = await fetch(nextUri, { headers: client.headers }); + const nextData = await nextRes.json(); + if (nextData.data) rows = rows.concat(nextData.data); + nextUri = nextData.nextUri; + } + return rows; +} + +// Helper: Use Trino passthrough table function to fetch functions/procedures +export async function fetchTrinoFunctionsAndProceduresPassthrough( + client: { baseUrl: string; headers: Record }, + catalog: string, + schema: string, +): Promise<{ + functions: TrinoFunction[]; + procedures: TrinoProcedure[]; +}> { + const functions: TrinoFunction[] = []; + const procedures: TrinoProcedure[] = []; + + if (catalog.toLowerCase() === 'postgresql') { + const passthrough = `SELECT * FROM TABLE(postgresql.system.query(query => ' + SELECT p.proname as function_name, n.nspname as schema_name, + pg_catalog.pg_get_function_result(p.oid) as return_type, + pg_catalog.pg_get_function_arguments(p.oid) as arguments, + CASE p.prokind + WHEN ''f'' THEN ''FUNCTION'' + WHEN ''p'' THEN ''PROCEDURE'' + WHEN ''a'' THEN ''AGGREGATE'' + WHEN ''w'' THEN ''WINDOW'' + ELSE p.prokind::text + END as kind + FROM pg_catalog.pg_proc p + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ''${schema}'' + AND p.proname NOT LIKE ''pg_%'' + ORDER BY p.proname + '))`; + try { + const rows = await executeTrinoQuery(client, passthrough); + for (const row of rows) { + const [name, schemaName, returnType, args, kind] = row; + if (kind === 'FUNCTION') { + functions.push({ + catalog: catalog || '', + schema: schemaName || '', + name: name || '', + returnType: returnType || 'unknown', + argumentTypes: args + ? args.split(',').map((a: string) => a.trim()) + : [], + }); + } else if (kind === 'PROCEDURE') { + procedures.push({ + catalog: catalog || '', + schema: schemaName || '', + name: name || '', + argumentTypes: args + ? args.split(',').map((a: string) => a.trim()) + : [], + }); + } + } + } catch (e) { + // console.log('[DEBUG] Passthrough for PostgreSQL failed:', e); + } + } else if (catalog.toLowerCase() === 'mysql') { + const passthrough = `SELECT * FROM TABLE(mysql.system.query(query => ' + SELECT routine_name, routine_type, data_type, routine_definition + FROM information_schema.routines + WHERE routine_schema = ''${schema}'' + '))`; + try { + const rows = await executeTrinoQuery(client, passthrough); + for (const row of rows) { + const [name, routineType, returnType, _def] = row; + if ( + routineType && + routineType.trim().toUpperCase().startsWith('FUNCTION') + ) { + functions.push({ + catalog: catalog || '', + schema: schema || '', + name: name || '', + returnType: returnType || 'unknown', + argumentTypes: [], + }); + } else if ( + routineType && + routineType.trim().toUpperCase().startsWith('PROCEDURE') + ) { + procedures.push({ + catalog: catalog || '', + schema: schema || '', + name: name || '', + argumentTypes: [], + }); + } + } + } catch (e) { + // console.log('[DEBUG] Passthrough for MySQL failed:', e); + } + } + return { functions, procedures }; +} + +// Fetch catalogs, schemas, tables, and columns from Trino +export async function fetchTrinoSchema( + client: { baseUrl: string; headers: Record }, + options: { catalog?: string; schema?: string }, +): Promise { + // 1. Catalogs + const catalogQuery = options.catalog + ? `SHOW CATALOGS LIKE '${options.catalog}'` + : 'SHOW CATALOGS'; + const catalogRows = await executeTrinoQuery(client, catalogQuery); + let catalogs: TrinoCatalog[] = catalogRows + .map(row => ({ name: row[0] ?? '' })) + .filter(c => c.name); + // Always exclude system/admin/internal catalogs + const adminCatalogs = new Set(['system', 'information_schema']); + catalogs = catalogs.filter(c => !adminCatalogs.has(c.name.toLowerCase())); + + // 2. Schemas + let schemas: TrinoSchema[] = []; + for (const catalog of catalogs) { + const schemaQuery = options.schema + ? `SHOW SCHEMAS FROM ${catalog.name} LIKE '${options.schema}'` + : `SHOW SCHEMAS FROM ${catalog.name}`; + const schemaRows = await executeTrinoQuery(client, schemaQuery); + let theseSchemas = schemaRows + .map(row => ({ catalog: catalog.name, name: row[0] ?? '' })) + .filter(s => s.name); + // Always exclude system/admin/internal schemas + const adminSchemas = new Set([ + 'information_schema', + 'sys', + 'performance_schema', + 'mysql', + 'pg_catalog', + 'system', + ]); + theseSchemas = theseSchemas.filter( + s => !adminSchemas.has(s.name.toLowerCase()), + ); + schemas.push(...theseSchemas); + } + + // 3. Get table comments to help identify object types + const tableComments = new Map(); + try { + const commentsQuery = ` + SELECT catalog_name, schema_name, table_name, comment + FROM system.metadata.table_comments + WHERE comment IS NOT NULL + `; + const commentRows = await executeTrinoQuery(client, commentsQuery); + for (const row of commentRows) { + const [catalog, schema, table, comment] = row; + if (catalog && schema && table && comment) { + const key = `${catalog}.${schema}.${table}`; + tableComments.set(key, comment); + } + } + } catch (_) { + // console.log('[DEBUG] Could not fetch table comments'); + } + + // 4. Tables, Views, and Materialized Views + const tables: TrinoTable[] = []; + const views: TrinoView[] = []; + const materializedViews: TrinoMaterializedView[] = []; + + for (const schema of schemas) { + // Get all objects from information_schema + const tableQuery = `SELECT table_name, table_type FROM ${schema.catalog}.information_schema.tables WHERE table_schema = '${schema.name}'`; + const tableRows = await executeTrinoQuery(client, tableQuery); + + for (const [tableNameRaw, tableTypeRaw] of tableRows) { + const tableName = tableNameRaw ?? ''; + const reportedType = tableTypeRaw ?? ''; + if (!tableName) continue; + + // Get columns first + let columns: TrinoColumn[] = []; + try { + const columnsQuery = `SHOW COLUMNS FROM ${schema.catalog}.${schema.name}.${tableName}`; + const columnRows = await executeTrinoQuery(client, columnsQuery); + columns = columnRows + .map(row => ({ + name: row[0] ?? '', + type: row[1] ?? '', + nullable: row[3] !== 'NO', // Column 3 is Null (YES/NO) + })) + .filter(col => col.name && col.type); + } catch (_) { + // console.log(`[DEBUG] Failed to get columns for ${tableName}`); + } + + // Determine actual object type using multiple strategies + let actualType: 'TABLE' | 'VIEW' | 'MATERIALIZED VIEW' = 'TABLE'; + + const VIEW_TYPE = 'VIEW'; + const MATERIALIZED_VIEW_TYPE = 'MATERIALIZED VIEW'; + + // Strategy 1: Check table comments (most reliable for MySQL) + const commentKey = `${schema.catalog}.${schema.name}.${tableName}`; + const comment = tableComments.get(commentKey); + if (comment) { + if (comment.toUpperCase() === 'VIEW') { + actualType = VIEW_TYPE; + } else if (comment.toUpperCase().includes('MATERIALIZED')) { + actualType = MATERIALIZED_VIEW_TYPE; + } + } + + // Strategy 2: Check if reported type gives us a hint (some connectors might work) + if (actualType === 'TABLE' && reportedType.toUpperCase() === VIEW_TYPE) { + actualType = VIEW_TYPE; + } else if ( + actualType === 'TABLE' && + reportedType.toUpperCase() === MATERIALIZED_VIEW_TYPE + ) { + actualType = MATERIALIZED_VIEW_TYPE; + } + + // Strategy 3: Try to get CREATE statement to determine type + if (actualType === 'TABLE') { + try { + const createQuery = `SHOW CREATE TABLE ${schema.catalog}.${schema.name}.${tableName}`; + const createRows = await executeTrinoQuery(client, createQuery); + if (createRows.length > 0 && createRows[0] && createRows[0][0]) { + const createStatement = createRows[0][0].toString(); + const upperStatement = createStatement.toUpperCase(); + + // Check for VIEW patterns + if ( + upperStatement.includes('CREATE VIEW') || + upperStatement.includes('CREATE OR REPLACE VIEW') + ) { + actualType = VIEW_TYPE; + } else if (upperStatement.includes('CREATE MATERIALIZED VIEW')) { + actualType = MATERIALIZED_VIEW_TYPE; + } else if ( + upperStatement.includes('SELECT') && + upperStatement.includes('FROM') && + !upperStatement.includes('CREATE TABLE') + ) { + // Sometimes views are shown as CREATE TABLE but contain SELECT...FROM + actualType = VIEW_TYPE; + } + } + } catch (_) { + // Ignore errors from SHOW CREATE TABLE + } + } + + // Strategy 4: Compare column counts (views often have fewer columns than source tables) + if (actualType === 'TABLE' && columns.length > 0) { + try { + // Check if this might be a view by looking for a table with similar name but more columns + const baseName = tableName + .replace(/_view$|_v$|_mv$|_materialized$/i, '') + .replace(/^active_|^v_/i, ''); + if (baseName !== tableName) { + // This table has a view-like name, check if base table exists + const baseTableQuery = `SELECT COUNT(*) FROM ${schema.catalog}.information_schema.columns WHERE table_schema = '${schema.name}' AND table_name = '${baseName}'`; + const baseResult = await executeTrinoQuery(client, baseTableQuery); + if ( + baseResult.length > 0 && + baseResult[0] && + baseResult[0][0] !== undefined + ) { + const baseColumnCount = Number(baseResult[0][0]); + if (baseColumnCount > columns.length) { + actualType = VIEW_TYPE; + } + } + } + } catch (_) { + // Ignore errors + } + } + + // Strategy 5: Use naming conventions as a final fallback + if (actualType === 'TABLE') { + const lowerName = tableName.toLowerCase(); + if ( + lowerName.endsWith('_view') || + lowerName.endsWith('_v') || + lowerName.includes('_view_') || + lowerName.startsWith('v_') || + lowerName.startsWith('active_') + ) { + actualType = VIEW_TYPE; + } else if ( + lowerName.endsWith('_mv') || + lowerName.endsWith('_materialized') || + lowerName.includes('_mv_') + ) { + actualType = MATERIALIZED_VIEW_TYPE; + } + } + + // Add to appropriate collection + + if (actualType === VIEW_TYPE) { + views.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + columns, + }); + } else if (actualType === MATERIALIZED_VIEW_TYPE) { + materializedViews.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + columns, + }); + } else { + tables.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + type: 'BASE TABLE', + columns, + }); + } + } + } + + // 5. Functions - Note: Most Trino connectors don't expose functions + let functions: TrinoFunction[] = []; + let procedures: TrinoProcedure[] = []; + for (const schema of schemas) { + const { functions: passthroughFuncs, procedures: passthroughProcs } = + await fetchTrinoFunctionsAndProceduresPassthrough( + client, + schema.catalog, + schema.name, + ); + functions = functions.concat(passthroughFuncs); + procedures = procedures.concat(passthroughProcs); + } + + return { + catalogs, + schemas, + tables, + functions, + views, + materializedViews, + procedures, + }; +} diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index e739e8d2..a8492f9d 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -4,6 +4,7 @@ vi.mock('pastel', () => ({ default: vi.fn().mockImplementation(() => ({ run: vi.fn(() => Promise.resolve()), })), + option: vi.fn(config => config), })); import Pastel from 'pastel'; @@ -12,7 +13,15 @@ describe('Cli script', () => { it('Should run the pastel app', async () => { await import('../source/cli.js'); expect(Pastel).toHaveBeenCalled(); - const pastelInstance = Pastel.mock.results[0].value; + const pastelInstance = (Pastel as any).mock.results[0].value; expect(pastelInstance.run).toHaveBeenCalled(); }); + + it('Should include trino command in available commands', async () => { + // Import the trino command to ensure it's available + const trinoCommand = await import('../source/commands/env/apply/trino.js'); + expect(trinoCommand.description).toBeDefined(); + expect(trinoCommand.options).toBeDefined(); + expect(trinoCommand.default).toBeDefined(); + }); }); diff --git a/tests/components/env/trino/TrinoComponent.test.tsx b/tests/components/env/trino/TrinoComponent.test.tsx new file mode 100644 index 00000000..54818dae --- /dev/null +++ b/tests/components/env/trino/TrinoComponent.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TrinoComponent from '../../../../source/components/env/trino/TrinoComponent.js'; +import type { TrinoOptions } from '../../../../source/components/env/trino/types.js'; + +// Mock the useTrinoProcessor hook +vi.mock('../../../../source/hooks/trino/useTrinoProcessor.js', () => ({ + useTrinoProcessor: vi.fn(), +})); + +import { useTrinoProcessor } from '../../../../source/hooks/trino/useTrinoProcessor.js'; + +describe('TrinoComponent', () => { + const mockOptions: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + password: 'testpass', + catalog: 'postgresql', + schema: 'public', + insecure: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render ready state initially', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'idle', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Ready to process Trino schema'); + }); + + it('should render processing state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'processing', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain( + 'Processing Trino schema and syncing with Permit', + ); + }); + + it('should render error state with error message', () => { + const errorMessage = 'Connection failed'; + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'error', + errorMessage, + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Error: Connection failed'); + }); + + it('should render success state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'done', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain( + 'Trino schema successfully synced with Permit', + ); + }); + + it('should call processTrinoSchema on mount with props', async () => { + const mockProcessTrinoSchema = vi.fn(); + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: mockProcessTrinoSchema, + status: 'idle', + errorMessage: '', + }); + + render(); + await vi.waitFor(() => { + expect(mockProcessTrinoSchema).toHaveBeenCalledWith(mockOptions); + }); + }); + + it('should handle undefined status gracefully', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: undefined, + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Ready to process Trino schema'); + }); + + it('should handle empty error message in error state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'error', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Error:'); + }); +}); diff --git a/tests/env/apply/trino.test.tsx b/tests/env/apply/trino.test.tsx new file mode 100644 index 00000000..5ca2e658 --- /dev/null +++ b/tests/env/apply/trino.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { describe, it, expect, vi } from 'vitest'; +import Trino from '../../../source/commands/env/apply/trino.js'; + +vi.mock('../../../source/components/AuthProvider.js', () => { + return { + __esModule: true, + AuthProvider: function AuthProvider({ + children, + }: { + children: React.ReactNode; + }) { + return <>{children}; + }, + }; +}); + +vi.mock('../../../source/components/env/trino/TrinoComponent.js', () => ({ + __esModule: true, + default: ({ url, user }: { url: string; user: string }) => ( + + TrinoComponentMock url={url} user={user} + + ), +})); + +describe('permit env apply trino CLI command', () => { + it('renders the TrinoComponent and passes props', async () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('TrinoComponentMock'); + expect(lastFrame()).toContain('testuser'); + }); +}); diff --git a/tests/hooks/trino/useTrinoProcessor.test.tsx b/tests/hooks/trino/useTrinoProcessor.test.tsx new file mode 100644 index 00000000..505a38d4 --- /dev/null +++ b/tests/hooks/trino/useTrinoProcessor.test.tsx @@ -0,0 +1,358 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useTrinoProcessor } from '../../../source/hooks/trino/useTrinoProcessor.js'; +import type { TrinoOptions } from '../../../source/components/env/trino/types.js'; + +// Mock the trinoUtils functions +vi.mock('../../../source/utils/trinoUtils.js', () => ({ + connectToTrino: vi.fn(), + fetchTrinoSchema: vi.fn(), + mapTrinoSchemaToPermitResources: vi.fn(), +})); + +// Mock the useResourcesApi hook +vi.mock('../../../source/hooks/useResourcesApi.js', () => ({ + useResourcesApi: vi.fn(), +})); + +import { + connectToTrino, + fetchTrinoSchema, + mapTrinoSchemaToPermitResources, +} from '../../../source/utils/trinoUtils.js'; +import { useResourcesApi } from '../../../source/hooks/useResourcesApi.js'; + +describe('useTrinoProcessor', () => { + const mockOptions: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + password: 'testpass', + catalog: 'postgresql', + schema: 'public', + insecure: false, + }; + + const mockTrinoClient = { + baseUrl: 'http://localhost:8080', + headers: { 'X-Trino-User': 'testuser' }, + }; + + const mockTrinoSchema = { + catalogs: [{ name: 'postgresql' }], + schemas: [{ catalog: 'postgresql', name: 'public' }], + tables: [ + { + catalog: 'postgresql', + schema: 'public', + name: 'users', + type: 'BASE TABLE', + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'email', type: 'varchar', nullable: false }, + ], + }, + ], + }; + + const mockPermitResources = [ + { + key: 'postgresql', + name: 'postgresql', + description: 'Trino catalog: postgresql', + actions: ['access_catalog', 'show_schemas'], + }, + { + key: 'postgresql|public|users', + name: 'postgresql.public.users', + description: 'BASE TABLE users in postgresql.public', + actions: ['select', 'insert', 'update', 'delete'], + attributes: { + id: { type: 'number', description: undefined }, + email: { type: 'string', description: undefined }, + }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should process Trino schema successfully', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).toHaveBeenCalledWith( + mockTrinoSchema, + ); + expect(mockCreateBulkResources).toHaveBeenCalledWith([ + { + key: 'postgresql', + name: 'postgresql', + description: 'Trino catalog: postgresql', + actions: { access_catalog: {}, show_schemas: {} }, + }, + { + key: 'postgresql|public|users', + name: 'postgresql.public.users', + description: 'BASE TABLE users in postgresql.public', + actions: { select: {}, insert: {}, update: {}, delete: {} }, + attributes: { + id: { type: 'number', description: undefined }, + email: { type: 'string', description: undefined }, + }, + }, + ]); + }); + + it('should handle connection errors', async () => { + const mockCreateBulkResources = vi.fn(); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + const connectionError = new Error('Connection failed'); + (connectToTrino as any).mockImplementation(() => { + throw connectionError; + }); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).not.toHaveBeenCalled(); + expect(mapTrinoSchemaToPermitResources).not.toHaveBeenCalled(); + expect(mockCreateBulkResources).not.toHaveBeenCalled(); + }); + + it('should handle schema fetching errors', async () => { + const mockCreateBulkResources = vi.fn(); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + const fetchError = new Error('Schema fetch failed'); + (fetchTrinoSchema as any).mockRejectedValue(fetchError); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).not.toHaveBeenCalled(); + expect(mockCreateBulkResources).not.toHaveBeenCalled(); + }); + + it('should handle resource creation errors', async () => { + const mockCreateBulkResources = vi + .fn() + .mockRejectedValue(new Error('API failed')); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).toHaveBeenCalledWith( + mockTrinoSchema, + ); + expect(mockCreateBulkResources).toHaveBeenCalled(); + }); + + it('should handle options without catalog and schema', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const optionsWithoutFilters: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + }; + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(optionsWithoutFilters) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: undefined, + schema: undefined, + }); + }); + + it('should return status and error message from useResourcesApi', () => { + (useResourcesApi as any).mockReturnValue({ + createBulkResources: vi.fn(), + status: 'processing', + errorMessage: 'Test error', + }); + + const TestComponent = () => { + const { status, errorMessage } = useTrinoProcessor(); + return {`${status}:${errorMessage}`}; + }; + + const { lastFrame } = render(); + expect(lastFrame()).toBe('processing:Test error'); + }); + + it('should handle empty schema data', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue({ + catalogs: [], + schemas: [], + tables: [], + }); + (mapTrinoSchemaToPermitResources as any).mockReturnValue([]); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(mockCreateBulkResources).toHaveBeenCalledWith([]); + }); +}); diff --git a/tests/trino/README.md b/tests/trino/README.md new file mode 100644 index 00000000..1e14bd2e --- /dev/null +++ b/tests/trino/README.md @@ -0,0 +1,93 @@ +# Trino Test Environment + +This directory contains a Docker Compose setup for testing the `permit env apply trino` command with a real Trino cluster and sample databases. + +## Setup + +1. **Start the environment:** + + ```bash + cd tests/trino + docker-compose up -d + ``` + +2. **Wait for services to be healthy:** + + ```bash + docker-compose ps + ``` + + All services should show "healthy" status. + +3. **Verify Trino is accessible:** + ```bash + curl http://localhost:8080/v1/info + ``` + +## Test Data + +The setup includes two databases with sample data: + +### PostgreSQL (catalog: postgresql) + +- **Schema:** public +- **Tables:** users, products, orders, order_items +- **Sample data:** 3 users, 4 products, 3 orders + +### MySQL (catalog: mysql) + +- **Schema:** testdb +- **Tables:** customers, inventory, transactions, transaction_items +- **Sample data:** 3 customers, 4 inventory items, 3 transactions + +## Testing the CLI Command + +Once the environment is running, you can test the `permit env apply trino` command: + +```bash +# Test with all catalogs +permit env apply trino --url http://localhost:8080 --user test + +# Test with specific catalog +permit env apply trino --url http://localhost:8080 --user test --catalog postgresql + +# Test with specific schema +permit env apply trino --url http://localhost:8080 --user test --catalog postgresql --schema public +``` + +## Expected Resources + +The command should create the following Permit resources: + +### Catalogs + +- `postgresql` - PostgreSQL catalog +- `mysql` - MySQL catalog + +### Schemas + +- `postgresql|public` - Public schema in PostgreSQL +- `mysql|testdb` - TestDB schema in MySQL + +### Tables (with columns as attributes) + +- `postgresql|public|users` - Users table with columns as attributes +- `postgresql|public|products` - Products table with columns as attributes +- `postgresql|public|orders` - Orders table with columns as attributes +- `postgresql|public|order_items` - Order items table with columns as attributes +- `mysql|testdb|customers` - Customers table with columns as attributes +- `mysql|testdb|inventory` - Inventory table with columns as attributes +- `mysql|testdb|transactions` - Transactions table with columns as attributes +- `mysql|testdb|transaction_items` - Transaction items table with columns as attributes + +### Columns (as separate resources) + +- Each column in each table as a separate resource with hierarchical keys + +## Cleanup + +To stop and remove the environment: + +```bash +docker-compose down -v +``` diff --git a/tests/trino/docker-compose.yml b/tests/trino/docker-compose.yml new file mode 100644 index 00000000..e7815389 --- /dev/null +++ b/tests/trino/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + # PostgreSQL database + postgres: + image: postgres:15 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + ports: + - '5432:5432' + volumes: + - ./sample_data/postgres_init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U testuser -d testdb'] + interval: 10s + timeout: 5s + retries: 5 + + # MySQL database + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: testdb + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + ports: + - '3306:3306' + volumes: + - ./sample_data/mysql_init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: + [ + 'CMD', + 'mysqladmin', + 'ping', + '-h', + 'localhost', + '-u', + 'testuser', + '-ptestpass', + ] + interval: 10s + timeout: 5s + retries: 5 + + # Trino server + trino: + image: trinodb/trino:latest + ports: + - '8080:8080' + volumes: + - ./trino_config:/etc/trino + depends_on: + postgres: + condition: service_healthy + mysql: + condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/v1/info'] + interval: 10s + timeout: 5s + retries: 5 diff --git a/tests/trino/sample_data/mysql_init.sql b/tests/trino/sample_data/mysql_init.sql new file mode 100644 index 00000000..a51aa9ab --- /dev/null +++ b/tests/trino/sample_data/mysql_init.sql @@ -0,0 +1,96 @@ +-- Create customers table +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create inventory table +CREATE TABLE inventory ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + category VARCHAR(100), + stock_quantity INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create transactions table +CREATE TABLE transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT, + total_amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) +); + +-- Create transaction_items table +CREATE TABLE transaction_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + transaction_id INT, + inventory_id INT, + quantity INT NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + FOREIGN KEY (transaction_id) REFERENCES transactions(id), + FOREIGN KEY (inventory_id) REFERENCES inventory(id) +); + +-- Insert sample data +INSERT INTO customers (email, first_name, last_name, is_active) VALUES +('alice.johnson@example.com', 'Alice', 'Johnson', TRUE), +('charlie.brown@example.com', 'Charlie', 'Brown', TRUE), +('diana.prince@example.com', 'Diana', 'Prince', FALSE); + +INSERT INTO inventory (name, description, price, category, stock_quantity) VALUES +('Tablet', '10-inch tablet', 299.99, 'Electronics', 30), +('Headphones', 'Noise-cancelling headphones', 199.99, 'Electronics', 60), +('Monitor', '24-inch monitor', 149.99, 'Electronics', 40), +('Desk', 'Office desk', 199.99, 'Furniture', 25); + +INSERT INTO transactions (customer_id, total_amount, status) VALUES +(1, 499.98, 'completed'), +(2, 149.99, 'pending'), +(1, 199.99, 'shipped'); + +INSERT INTO transaction_items (transaction_id, inventory_id, quantity, unit_price) VALUES +(1, 1, 1, 299.99), +(1, 2, 1, 199.99), +(2, 3, 1, 149.99), +(3, 4, 1, 199.99); + +-- View: active_customers +CREATE OR REPLACE VIEW active_customers AS +SELECT id, email, first_name, last_name +FROM customers +WHERE is_active = TRUE; + +-- Simulated Materialized View: expensive_inventory_mv +CREATE TABLE IF NOT EXISTS expensive_inventory_mv AS +SELECT id, name, price +FROM inventory +WHERE price > 200; + +-- Function: customer_full_name +DELIMITER // +CREATE FUNCTION customer_full_name(cid INT) RETURNS VARCHAR(255) +DETERMINISTIC +BEGIN + DECLARE fname VARCHAR(100); + DECLARE lname VARCHAR(100); + SELECT first_name, last_name INTO fname, lname FROM customers WHERE id = cid; + RETURN CONCAT(fname, ' ', lname); +END // +DELIMITER ; + +-- Procedure: activate_customer +DELIMITER // +CREATE PROCEDURE activate_customer(IN cid INT) +BEGIN + UPDATE customers SET is_active = TRUE WHERE id = cid; +END // +DELIMITER ; \ No newline at end of file diff --git a/tests/trino/sample_data/postgres_init.sql b/tests/trino/sample_data/postgres_init.sql new file mode 100644 index 00000000..b3c78738 --- /dev/null +++ b/tests/trino/sample_data/postgres_init.sql @@ -0,0 +1,94 @@ +-- Create users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create products table +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + category VARCHAR(100), + stock_quantity INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create orders table +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + total_amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create order_items table +CREATE TABLE order_items ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(id), + product_id INTEGER REFERENCES products(id), + quantity INTEGER NOT NULL, + unit_price DECIMAL(10,2) NOT NULL +); + +-- Insert sample data +INSERT INTO users (email, first_name, last_name, is_active) VALUES +('john.doe@example.com', 'John', 'Doe', true), +('jane.smith@example.com', 'Jane', 'Smith', true), +('bob.wilson@example.com', 'Bob', 'Wilson', false); + +INSERT INTO products (name, description, price, category, stock_quantity) VALUES +('Laptop', 'High-performance laptop', 999.99, 'Electronics', 50), +('Mouse', 'Wireless mouse', 29.99, 'Electronics', 100), +('Keyboard', 'Mechanical keyboard', 89.99, 'Electronics', 75), +('Book', 'Programming guide', 49.99, 'Books', 200); + +INSERT INTO orders (user_id, total_amount, status) VALUES +(1, 1029.98, 'completed'), +(2, 89.99, 'pending'), +(1, 49.99, 'shipped'); + +INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES +(1, 1, 1, 999.99), +(1, 2, 1, 29.99), +(2, 3, 1, 89.99), +(3, 4, 1, 49.99); + +-- View: active_users +CREATE OR REPLACE VIEW active_users AS +SELECT id, email, first_name, last_name +FROM users +WHERE is_active = true; + +-- Materialized View: expensive_products_mv +CREATE MATERIALIZED VIEW expensive_products_mv AS +SELECT id, name, price +FROM products +WHERE price > 500; + +-- Function: user_full_name +CREATE OR REPLACE FUNCTION user_full_name(uid integer) +RETURNS text AS $$ +DECLARE + fname text; + lname text; +BEGIN + SELECT first_name, last_name INTO fname, lname FROM users WHERE id = uid; + RETURN fname || ' ' || lname; +END; +$$ LANGUAGE plpgsql; + +-- Procedure: activate_user +CREATE OR REPLACE PROCEDURE activate_user(uid integer) +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE users SET is_active = true WHERE id = uid; +END; +$$; \ No newline at end of file diff --git a/tests/trino/trino_config/catalog/mysql.properties b/tests/trino/trino_config/catalog/mysql.properties new file mode 100644 index 00000000..90cd1000 --- /dev/null +++ b/tests/trino/trino_config/catalog/mysql.properties @@ -0,0 +1,4 @@ +connector.name=mysql +connection-url=jdbc:mysql://mysql:3306 +connection-user=testuser +connection-password=testpass \ No newline at end of file diff --git a/tests/trino/trino_config/catalog/postgresql.properties b/tests/trino/trino_config/catalog/postgresql.properties new file mode 100644 index 00000000..5ffa49bf --- /dev/null +++ b/tests/trino/trino_config/catalog/postgresql.properties @@ -0,0 +1,4 @@ +connector.name=postgresql +connection-url=jdbc:postgresql://postgres:5432/testdb +connection-user=testuser +connection-password=testpass \ No newline at end of file diff --git a/tests/trino/trino_config/config.properties b/tests/trino/trino_config/config.properties new file mode 100644 index 00000000..7de6fb8d --- /dev/null +++ b/tests/trino/trino_config/config.properties @@ -0,0 +1,4 @@ +coordinator=true +node-scheduler.include-coordinator=true +http-server.http.port=8080 +discovery.uri=http://localhost:8080 \ No newline at end of file diff --git a/tests/trino/trino_config/jvm.config b/tests/trino/trino_config/jvm.config new file mode 100644 index 00000000..5489883d --- /dev/null +++ b/tests/trino/trino_config/jvm.config @@ -0,0 +1,7 @@ +-server +-Xmx4G +-XX:+UseG1GC +-XX:G1HeapRegionSize=32M +-XX:+ExplicitGCInvokesConcurrent +-XX:+HeapDumpOnOutOfMemoryError +-XX:+ExitOnOutOfMemoryError \ No newline at end of file diff --git a/tests/trino/trino_config/node.properties b/tests/trino/trino_config/node.properties new file mode 100644 index 00000000..c8be95c9 --- /dev/null +++ b/tests/trino/trino_config/node.properties @@ -0,0 +1,3 @@ +node.environment=test +node.data-dir=/tmp/trino/data +node.id=testnode1 \ No newline at end of file diff --git a/tests/utils/trinoUtils.test.tsx b/tests/utils/trinoUtils.test.tsx new file mode 100644 index 00000000..635adba9 --- /dev/null +++ b/tests/utils/trinoUtils.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { + mapTrinoSchemaToPermitResources, + trinoTypeToPermitType, // Now exported for testing + TrinoSchemaData, +} from '../../source/utils/trinoUtils.js'; + +describe('trinoTypeToPermitType', () => { + it('maps Trino types to Permit types', () => { + expect(trinoTypeToPermitType('varchar')).toBe('string'); + expect(trinoTypeToPermitType('integer')).toBe('number'); + expect(trinoTypeToPermitType('boolean')).toBe('bool'); + expect(trinoTypeToPermitType('json')).toBe('json'); + expect(trinoTypeToPermitType('timestamp')).toBe('time'); + expect(trinoTypeToPermitType('array')).toBe('array'); + expect(trinoTypeToPermitType('row')).toBe('object'); + }); +}); + +describe('mapTrinoSchemaToPermitResources', () => { + it('maps a simple Trino schema to Permit resources', () => { + const schema: TrinoSchemaData = { + catalogs: [{ name: 'testcat' }], + schemas: [{ catalog: 'testcat', name: 'public' }], + tables: [ + { + catalog: 'testcat', + schema: 'public', + name: 'users', + type: 'BASE TABLE', + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'email', type: 'varchar', nullable: false }, + { name: 'is_active', type: 'boolean', nullable: true }, + ], + }, + ], + functions: [], + views: [], + materializedViews: [], + procedures: [], + }; + const resources = mapTrinoSchemaToPermitResources(schema); + + // Check catalogs + const catalog = resources.find(r => r.key === 'trino-catalog-testcat'); + expect(catalog).toBeDefined(); + expect(catalog?.name).toBe('testcat'); + expect(catalog?.actions).toContain('AccessCatalog'); + + // Check schemas + const schemaResource = resources.find( + r => r.key === 'trino-schema-testcat-public', + ); + expect(schemaResource).toBeDefined(); + expect(schemaResource?.name).toBe('testcat.public'); + expect(schemaResource?.actions).toContain('CreateSchema'); + + // Check tables + const table = resources.find( + r => r.key === 'trino-table-testcat-public-users', + ); + expect(table).toBeDefined(); + expect(table?.name).toBe('testcat.public.users'); + expect(table?.actions).toContain('CreateTable'); + expect(table?.attributes).toBeDefined(); + expect(table?.attributes?.id).toEqual({ type: 'number' }); + expect(table?.attributes?.email).toEqual({ type: 'string' }); + + // Check columns + const column = resources.find( + r => r.key === 'trino-column-testcat-public-users-id', + ); + expect(column).toBeDefined(); + expect(column?.name).toBe('testcat.public.users.id'); + expect(column?.actions).toContain('SelectFromColumns'); + }); + + it('maps Trino functions, views, materialized views, and procedures to Permit resources', () => { + const schema: TrinoSchemaData = { + catalogs: [{ name: 'testcat' }], + schemas: [{ catalog: 'testcat', name: 'public' }], + tables: [], + functions: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_func', + returnType: 'integer', + argumentTypes: ['varchar', 'integer'], + }, + ], + views: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_view', + columns: [ + { name: 'col1', type: 'varchar', nullable: false }, + { name: 'col2', type: 'integer', nullable: true }, + ], + }, + ], + materializedViews: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_mview', + columns: [{ name: 'total', type: 'decimal', nullable: false }], + }, + ], + procedures: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_proc', + argumentTypes: ['varchar'], + }, + ], + }; + + const resources = mapTrinoSchemaToPermitResources(schema); + + // Check function + const func = resources.find( + r => r.key === 'trino-function-testcat-public-my_func', + ); + expect(func).toBeDefined(); + expect(func?.name).toBe('testcat.public.my_func'); + expect(func?.actions).toContain('ExecuteFunction'); + expect(func?.actions).toContain('ShowFunctions'); + expect(func?.attributes?.returnType).toEqual({ type: 'number' }); + expect(func?.attributes?.argumentTypes).toEqual({ type: 'array' }); + + // Check view + const view = resources.find( + r => r.key === 'trino-view-testcat-public-my_view', + ); + expect(view).toBeDefined(); + expect(view?.name).toBe('testcat.public.my_view'); + expect(view?.actions).toContain('CreateView'); + expect(view?.actions).toContain('DropView'); + expect(view?.attributes?.col1).toEqual({ type: 'string' }); + expect(view?.attributes?.col2).toEqual({ + type: 'number', + description: 'nullable', + }); + + // Check materialized view + const mview = resources.find( + r => r.key === 'trino-materialized_view-testcat-public-my_mview', + ); + expect(mview).toBeDefined(); + expect(mview?.name).toBe('testcat.public.my_mview'); + expect(mview?.actions).toContain('CreateMaterializedView'); + expect(mview?.actions).toContain('RefreshMaterializedView'); + expect(mview?.attributes?.total).toEqual({ type: 'number' }); + + // Check procedure + const proc = resources.find( + r => r.key === 'trino-procedure-testcat-public-my_proc', + ); + expect(proc).toBeDefined(); + expect(proc?.name).toBe('testcat.public.my_proc'); + expect(proc?.actions).toContain('ExecuteProcedure'); + expect(proc?.attributes?.argumentTypes).toEqual({ type: 'array' }); + }); +}); From 1c732f7bc3aac557c737cb05d1d74e87a661cad4 Mon Sep 17 00:00:00 2001 From: Gabriel Manor Date: Tue, 22 Jul 2025 23:37:57 +0300 Subject: [PATCH 11/34] Fix Tests --- tests/components/env/trino/TrinoComponent.test.tsx | 8 +++++++- tests/hooks/useResourceApi.test.tsx | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/components/env/trino/TrinoComponent.test.tsx b/tests/components/env/trino/TrinoComponent.test.tsx index 54818dae..2774c4e2 100644 --- a/tests/components/env/trino/TrinoComponent.test.tsx +++ b/tests/components/env/trino/TrinoComponent.test.tsx @@ -9,6 +9,13 @@ vi.mock('../../../../source/hooks/trino/useTrinoProcessor.js', () => ({ useTrinoProcessor: vi.fn(), })); +// Mock the dynamic import of trinoUtils +vi.mock('../../../../source/utils/trinoUtils.js', () => ({ + connectToTrino: vi.fn(() => ({})), + fetchTrinoSchema: vi.fn(() => ({})), + mapTrinoSchemaToPermitResources: vi.fn(() => []), +})); + import { useTrinoProcessor } from '../../../../source/hooks/trino/useTrinoProcessor.js'; describe('TrinoComponent', () => { @@ -18,7 +25,6 @@ describe('TrinoComponent', () => { password: 'testpass', catalog: 'postgresql', schema: 'public', - insecure: false, }; beforeEach(() => { diff --git a/tests/hooks/useResourceApi.test.tsx b/tests/hooks/useResourceApi.test.tsx index e7ca5d5d..f8e986a8 100644 --- a/tests/hooks/useResourceApi.test.tsx +++ b/tests/hooks/useResourceApi.test.tsx @@ -101,6 +101,9 @@ describe('useResourceApi', () => { expect(lastFrame()).toContain('Result: users,posts'); expect(mockGetFn).toHaveBeenCalledWith( '/v2/schema/{proj_id}/{env_id}/resources', + undefined, + undefined, + { page: 1, per_page: 100 }, ); }); From ef2ef6eda89a67235e34b8224012f41b3be567a8 Mon Sep 17 00:00:00 2001 From: Gabriel Manor Date: Tue, 22 Jul 2025 23:55:01 +0300 Subject: [PATCH 12/34] Fix CI Tests --- tests/cli.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index a8492f9d..4ddf8e6c 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -16,12 +16,4 @@ describe('Cli script', () => { const pastelInstance = (Pastel as any).mock.results[0].value; expect(pastelInstance.run).toHaveBeenCalled(); }); - - it('Should include trino command in available commands', async () => { - // Import the trino command to ensure it's available - const trinoCommand = await import('../source/commands/env/apply/trino.js'); - expect(trinoCommand.description).toBeDefined(); - expect(trinoCommand.options).toBeDefined(); - expect(trinoCommand.default).toBeDefined(); - }); }); From 25dae3c9153c94902feca4ee76c060ee182bfe6d Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Thu, 19 Jun 2025 15:36:16 +0530 Subject: [PATCH 13/34] chore: add a RBAC blog template --- source/templates/blog-rbac.tf | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 source/templates/blog-rbac.tf diff --git a/source/templates/blog-rbac.tf b/source/templates/blog-rbac.tf new file mode 100644 index 00000000..83c811eb --- /dev/null +++ b/source/templates/blog-rbac.tf @@ -0,0 +1,42 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.14" + } + } +} + +variable "PERMIT_API_KEY" { + type = string + description = "The API key for the Permit.io API" +} + +provider "permitio" { + api_url = "https://api.permit.io" + api_key = {{API_KEY}} +} + +# Resources +resource "permitio_resource" "blog" { + name = "blog" + description = "Blog resource for managing blog posts" + key = "blog" + + actions = { + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + } + } + attributes = { + } +} \ No newline at end of file From b95a587f228d99c35345e257ff3a17886a4969d5 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Thu, 19 Jun 2025 23:03:51 +0530 Subject: [PATCH 14/34] feat: add roles and user sets for blog resource in RBAC configuration --- source/templates/blog-rbac.tf | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/source/templates/blog-rbac.tf b/source/templates/blog-rbac.tf index 83c811eb..c8aae2fc 100644 --- a/source/templates/blog-rbac.tf +++ b/source/templates/blog-rbac.tf @@ -38,5 +38,37 @@ resource "permitio_resource" "blog" { } } attributes = { + "premium" = { + name = "Premium" + type = "string" + } } -} \ No newline at end of file +} + +# Roles +resource "permitio_role" "reader" { + key = "reader" + name = "reader" + permissions = ["blog:read"] + + depends_on = [permitio_resource.blog] +} + +# User Sets +resource "permitio_user_set" "permit_employee" { + key = "permit_employee" + name = "permit-employee" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.email" = { + contains = "permit.io" + } + } + ] + } + ] +}) +} From a524913949a95993072f4637c8dbb7620e769e48 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Thu, 19 Jun 2025 23:14:01 +0530 Subject: [PATCH 15/34] feat: add condition set rules and resource --- source/templates/blog-rbac.tf | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/source/templates/blog-rbac.tf b/source/templates/blog-rbac.tf index c8aae2fc..16aa72d0 100644 --- a/source/templates/blog-rbac.tf +++ b/source/templates/blog-rbac.tf @@ -54,17 +54,56 @@ resource "permitio_role" "reader" { depends_on = [permitio_resource.blog] } +# Condition Set Rules +resource "permitio_condition_set_rule" "free_premium_premium_blog_read" { + user_set = permitio_user_set.free_premium.key + permission = "blog:read" + resource_set = permitio_resource_set.premium.key + depends_on = [ + permitio_resource_set.premium, + permitio_user_set.free_premium + ] +} + +# Resource Sets +resource "permitio_resource_set" "premium" { + name = "premium" + key = "premium" + resource = permitio_resource.blog.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.premium": { + "equals": "true" + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.blog + ] +} + # User Sets -resource "permitio_user_set" "permit_employee" { - key = "permit_employee" - name = "permit-employee" +resource "permitio_user_set" "free_premium" { + key = "free_premium" + name = "free-premium" conditions = jsonencode({ allOf = [ { allOf = [ { "user.email" = { - contains = "permit.io" + equals = "un.org" + } + }, + { + "user.email" = { + equals = "who.org" } } ] @@ -72,3 +111,4 @@ resource "permitio_user_set" "permit_employee" { ] }) } + From de4513e721aa61717379dd497538f6e92fbe33d8 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Fri, 20 Jun 2025 16:19:56 +0530 Subject: [PATCH 16/34] chore: add condition set rules and resource sets for premium blog access --- source/templates/blog-access.tf | 191 ++++++++++++++++++++++++++++++++ source/templates/blog-rbac.tf | 114 ------------------- 2 files changed, 191 insertions(+), 114 deletions(-) create mode 100644 source/templates/blog-access.tf delete mode 100644 source/templates/blog-rbac.tf diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf new file mode 100644 index 00000000..0e29d5fc --- /dev/null +++ b/source/templates/blog-access.tf @@ -0,0 +1,191 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.14" + } + } +} + +variable "PERMIT_API_KEY" { + type = string + description = "The API key for the Permit.io API" +} + +provider "permitio" { + api_url = "https://api.permit.io" + api_key = {{API_KEY}} +} + +# Resources +resource "permitio_resource" "docs" { + name = "docs" + description = "" + key = "docs" + + actions = { + "delete" = { + name = "delete" + }, + "update" = { + name = "update" + }, + "create" = { + name = "create" + } + } + attributes = { + } +} +resource "permitio_resource" "blog" { + name = "blog" + description = "" + key = "blog" + + actions = { + "delete" = { + name = "delete" + }, + "update" = { + name = "update" + }, + "read" = { + name = "read" + }, + "create" = { + name = "create" + } + } + attributes = { + "premium" = { + name = "Premium" + type = "string" + } + } +} + +# Roles +resource "permitio_role" "blog__admin" { + key = "admin" + name = "admin" + resource = permitio_resource.blog.key + permissions = ["update", "delete", "create", "read"] + + depends_on = [permitio_resource.blog] +} +resource "permitio_role" "blog__editor" { + key = "editor" + name = "editor" + resource = permitio_resource.blog.key + permissions = ["read", "create", "update"] + + depends_on = [permitio_resource.blog] +} +resource "permitio_role" "blog__reader" { + key = "reader" + name = "reader" + resource = permitio_resource.blog.key + permissions = ["read"] + + depends_on = [permitio_resource.blog] +} +resource "permitio_role" "docs__editor" { + key = "editor" + name = "editor" + resource = permitio_resource.docs.key + permissions = ["update", "delete", "create"] + + depends_on = [permitio_resource.docs] +} +resource "permitio_role" "reader" { + key = "reader" + name = "reader" + permissions = ["blog:read"] + + depends_on = [permitio_resource.blog] +} + +# Relations +resource "permitio_relation" "blog_docs" { + key = "parent" + name = "parent" + subject_resource = permitio_resource.blog.key + object_resource = permitio_resource.docs.key + depends_on = [ + permitio_resource.docs, + permitio_resource.blog, + ] +} + +# Condition Set Rules +resource "permitio_condition_set_rule" "free_premium_premium_blog_read" { + user_set = permitio_user_set.free_premium.key + permission = "blog:read" + resource_set = permitio_resource_set.premium.key + depends_on = [ + permitio_resource_set.premium, + permitio_user_set.free_premium + ] +} + +# Resource Sets +resource "permitio_resource_set" "premium" { + name = "premium" + key = "premium" + resource = permitio_resource.blog.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.premium": { + "equals": "true" + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.blog + ] +} + +# User Sets +resource "permitio_user_set" "free_premium" { + key = "free_premium" + name = "free-premium" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.email" = { + equals = "un.org" + } + }, + { + "user.email" = { + equals = "who.org" + } + } + ] + } + ] +}) +} + +# Role Derivations +resource "permitio_role_derivation" "blog_editor_to_docs_editor" { + role = permitio_role.blog__editor.key + on_resource = permitio_resource.blog.key + to_role = permitio_role.docs__editor.key + resource = permitio_resource.docs.key + linked_by = permitio_relation.blog_docs.key + depends_on = [ + permitio_role.blog__editor, + permitio_resource.blog, + permitio_role.docs__editor, + permitio_resource.docs, + permitio_relation.blog_docs + ] diff --git a/source/templates/blog-rbac.tf b/source/templates/blog-rbac.tf deleted file mode 100644 index 16aa72d0..00000000 --- a/source/templates/blog-rbac.tf +++ /dev/null @@ -1,114 +0,0 @@ -terraform { - required_providers { - permitio = { - source = "permitio/permit-io" - version = "~> 0.0.14" - } - } -} - -variable "PERMIT_API_KEY" { - type = string - description = "The API key for the Permit.io API" -} - -provider "permitio" { - api_url = "https://api.permit.io" - api_key = {{API_KEY}} -} - -# Resources -resource "permitio_resource" "blog" { - name = "blog" - description = "Blog resource for managing blog posts" - key = "blog" - - actions = { - "read" = { - name = "read" - }, - "create" = { - name = "create" - }, - "update" = { - name = "update" - }, - "delete" = { - name = "delete" - } - } - attributes = { - "premium" = { - name = "Premium" - type = "string" - } - } -} - -# Roles -resource "permitio_role" "reader" { - key = "reader" - name = "reader" - permissions = ["blog:read"] - - depends_on = [permitio_resource.blog] -} - -# Condition Set Rules -resource "permitio_condition_set_rule" "free_premium_premium_blog_read" { - user_set = permitio_user_set.free_premium.key - permission = "blog:read" - resource_set = permitio_resource_set.premium.key - depends_on = [ - permitio_resource_set.premium, - permitio_user_set.free_premium - ] -} - -# Resource Sets -resource "permitio_resource_set" "premium" { - name = "premium" - key = "premium" - resource = permitio_resource.blog.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.premium": { - "equals": "true" - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.blog - ] -} - -# User Sets -resource "permitio_user_set" "free_premium" { - key = "free_premium" - name = "free-premium" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.email" = { - equals = "un.org" - } - }, - { - "user.email" = { - equals = "who.org" - } - } - ] - } - ] -}) -} - From 9f7304e99abe18229df540a37576101d5c8a91db Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 13:50:32 +0530 Subject: [PATCH 17/34] refactor: update blog resource configuration and roles for comment management --- source/templates/blog-access.tf | 80 +++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index 0e29d5fc..c3440daf 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -18,49 +18,52 @@ provider "permitio" { } # Resources -resource "permitio_resource" "docs" { - name = "docs" +resource "permitio_resource" "blog" { + name = "blog" description = "" - key = "docs" + key = "blog" actions = { + "create" = { + name = "create" + }, "delete" = { name = "delete" }, "update" = { name = "update" }, - "create" = { - name = "create" + "read" = { + name = "read" } } attributes = { + "premium" = { + name = "Premium" + type = "string" + } } } -resource "permitio_resource" "blog" { - name = "blog" +resource "permitio_resource" "comment" { + name = "comment" description = "" - key = "blog" + key = "comment" actions = { - "delete" = { - name = "delete" - }, - "update" = { - name = "update" - }, "read" = { name = "read" }, "create" = { name = "create" + }, + "delete" = { + name = "delete" + }, + "update" = { + name = "update" } } attributes = { - "premium" = { - name = "Premium" - type = "string" - } } } @@ -89,13 +92,21 @@ resource "permitio_role" "blog__reader" { depends_on = [permitio_resource.blog] } -resource "permitio_role" "docs__editor" { +resource "permitio_role" "comment__editor" { key = "editor" name = "editor" - resource = permitio_resource.docs.key - permissions = ["update", "delete", "create"] + resource = permitio_resource.comment.key + permissions = ["read"] - depends_on = [permitio_resource.docs] + depends_on = [permitio_resource.comment] +} +resource "permitio_role" "comment__admin" { + key = "admin" + name = "admin" + resource = permitio_resource.comment.key + permissions = ["read", "update", "create", "delete"] + + depends_on = [permitio_resource.comment] } resource "permitio_role" "reader" { key = "reader" @@ -106,13 +117,13 @@ resource "permitio_role" "reader" { } # Relations -resource "permitio_relation" "blog_docs" { +resource "permitio_relation" "blog_comment" { key = "parent" name = "parent" subject_resource = permitio_resource.blog.key - object_resource = permitio_resource.docs.key + object_resource = permitio_resource.comment.key depends_on = [ - permitio_resource.docs, + permitio_resource.comment, permitio_resource.blog, ] } @@ -176,16 +187,17 @@ resource "permitio_user_set" "free_premium" { } # Role Derivations -resource "permitio_role_derivation" "blog_editor_to_docs_editor" { - role = permitio_role.blog__editor.key +resource "permitio_role_derivation" "blog_admin_to_comment_admin" { + role = permitio_role.blog__admin.key on_resource = permitio_resource.blog.key - to_role = permitio_role.docs__editor.key - resource = permitio_resource.docs.key - linked_by = permitio_relation.blog_docs.key + to_role = permitio_role.comment__admin.key + resource = permitio_resource.comment.key + linked_by = permitio_relation.blog_comment.key depends_on = [ - permitio_role.blog__editor, + permitio_role.blog__admin, permitio_resource.blog, - permitio_role.docs__editor, - permitio_resource.docs, - permitio_relation.blog_docs + permitio_role.comment__admin, + permitio_resource.comment, + permitio_relation.blog_comment ] +} From f48045386dfb94d7604b028776d3033d735fd7ee Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 14:02:16 +0530 Subject: [PATCH 18/34] refactor: reorganize permissions for blog and comment resources, and remove unused condition set rules --- source/templates/blog-access.tf | 37 +++++++++++++++------------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index c3440daf..b6b8d39a 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -27,14 +27,14 @@ resource "permitio_resource" "blog" { "create" = { name = "create" }, - "delete" = { - name = "delete" - }, "update" = { name = "update" }, "read" = { name = "read" + }, + "delete" = { + name = "delete" } } attributes = { @@ -50,15 +50,15 @@ resource "permitio_resource" "comment" { key = "comment" actions = { + "delete" = { + name = "delete" + }, "read" = { name = "read" }, "create" = { name = "create" }, - "delete" = { - name = "delete" - }, "update" = { name = "update" } @@ -72,7 +72,7 @@ resource "permitio_role" "blog__admin" { key = "admin" name = "admin" resource = permitio_resource.blog.key - permissions = ["update", "delete", "create", "read"] + permissions = ["delete"] depends_on = [permitio_resource.blog] } @@ -80,7 +80,7 @@ resource "permitio_role" "blog__editor" { key = "editor" name = "editor" resource = permitio_resource.blog.key - permissions = ["read", "create", "update"] + permissions = ["update", "read", "create"] depends_on = [permitio_resource.blog] } @@ -96,7 +96,15 @@ resource "permitio_role" "comment__editor" { key = "editor" name = "editor" resource = permitio_resource.comment.key - permissions = ["read"] + permissions = ["update", "create", "read"] + + depends_on = [permitio_resource.comment] +} +resource "permitio_role" "comment__reader" { + key = "reader" + name = "reader" + resource = permitio_resource.comment.key + permissions = ["read", "create"] depends_on = [permitio_resource.comment] } @@ -128,17 +136,6 @@ resource "permitio_relation" "blog_comment" { ] } -# Condition Set Rules -resource "permitio_condition_set_rule" "free_premium_premium_blog_read" { - user_set = permitio_user_set.free_premium.key - permission = "blog:read" - resource_set = permitio_resource_set.premium.key - depends_on = [ - permitio_resource_set.premium, - permitio_user_set.free_premium - ] -} - # Resource Sets resource "permitio_resource_set" "premium" { name = "premium" From 7c4b18022c69eb367fcbbe9c9b8b13afb8c45295 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 14:14:38 +0530 Subject: [PATCH 19/34] chore: add descriptions for blog and comment resources in Terraform template --- source/templates/blog-access.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index b6b8d39a..4705f904 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -20,7 +20,7 @@ provider "permitio" { # Resources resource "permitio_resource" "blog" { name = "blog" - description = "" + description = "resource representing a blog entity and its access actions" key = "blog" actions = { @@ -46,7 +46,7 @@ resource "permitio_resource" "blog" { } resource "permitio_resource" "comment" { name = "comment" - description = "" + description = "resource for managing access to blog comments" key = "comment" actions = { From bab5fb448ff7512c11b914309036f6ec298924f6 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 18:25:27 +0530 Subject: [PATCH 20/34] refactor: rename blog resources and roles to post for improved clarity and consistency --- source/templates/blog-access.tf | 87 ++++++++++----------------------- 1 file changed, 25 insertions(+), 62 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index 4705f904..900351cb 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -7,21 +7,16 @@ terraform { } } -variable "PERMIT_API_KEY" { - type = string - description = "The API key for the Permit.io API" -} - provider "permitio" { api_url = "https://api.permit.io" api_key = {{API_KEY}} } # Resources -resource "permitio_resource" "blog" { - name = "blog" - description = "resource representing a blog entity and its access actions" - key = "blog" +resource "permitio_resource" "post" { + name = "post" + description = "resource representing a post entity and its access actions" + key = "post" actions = { "create" = { @@ -46,7 +41,7 @@ resource "permitio_resource" "blog" { } resource "permitio_resource" "comment" { name = "comment" - description = "resource for managing access to blog comments" + description = "resource for managing access to post comments" key = "comment" actions = { @@ -68,29 +63,21 @@ resource "permitio_resource" "comment" { } # Roles -resource "permitio_role" "blog__admin" { +resource "permitio_role" "post__admin" { key = "admin" name = "admin" - resource = permitio_resource.blog.key + resource = permitio_resource.post.key permissions = ["delete"] - depends_on = [permitio_resource.blog] + depends_on = [permitio_resource.post] } -resource "permitio_role" "blog__editor" { +resource "permitio_role" "post__editor" { key = "editor" name = "editor" - resource = permitio_resource.blog.key + resource = permitio_resource.post.key permissions = ["update", "read", "create"] - depends_on = [permitio_resource.blog] -} -resource "permitio_role" "blog__reader" { - key = "reader" - name = "reader" - resource = permitio_resource.blog.key - permissions = ["read"] - - depends_on = [permitio_resource.blog] + depends_on = [permitio_resource.post] } resource "permitio_role" "comment__editor" { key = "editor" @@ -119,20 +106,20 @@ resource "permitio_role" "comment__admin" { resource "permitio_role" "reader" { key = "reader" name = "reader" - permissions = ["blog:read"] + permissions = ["post:read"] - depends_on = [permitio_resource.blog] + depends_on = [permitio_resource.post] } # Relations -resource "permitio_relation" "blog_comment" { +resource "permitio_relation" "post_comment" { key = "parent" name = "parent" - subject_resource = permitio_resource.blog.key + subject_resource = permitio_resource.post.key object_resource = permitio_resource.comment.key depends_on = [ permitio_resource.comment, - permitio_resource.blog, + permitio_resource.post, ] } @@ -140,7 +127,7 @@ resource "permitio_relation" "blog_comment" { resource "permitio_resource_set" "premium" { name = "premium" key = "premium" - resource = permitio_resource.blog.key + resource = permitio_resource.post.key conditions = jsonencode({ "allOf": [ { @@ -155,46 +142,22 @@ resource "permitio_resource_set" "premium" { ] }) depends_on = [ - permitio_resource.blog - ] -} - -# User Sets -resource "permitio_user_set" "free_premium" { - key = "free_premium" - name = "free-premium" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.email" = { - equals = "un.org" - } - }, - { - "user.email" = { - equals = "who.org" - } - } - ] - } + permitio_resource.post ] -}) } # Role Derivations -resource "permitio_role_derivation" "blog_admin_to_comment_admin" { - role = permitio_role.blog__admin.key - on_resource = permitio_resource.blog.key +resource "permitio_role_derivation" "post_admin_to_comment_admin" { + role = permitio_role.post__admin.key + on_resource = permitio_resource.post.key to_role = permitio_role.comment__admin.key resource = permitio_resource.comment.key - linked_by = permitio_relation.blog_comment.key + linked_by = permitio_relation.post_comment.key depends_on = [ - permitio_role.blog__admin, - permitio_resource.blog, + permitio_role.post__admin, + permitio_resource.post, permitio_role.comment__admin, permitio_resource.comment, - permitio_relation.blog_comment + permitio_relation.post_comment ] } From 3e00c89f20c7f04510a8c1a021e9511520948d19 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 18:45:51 +0530 Subject: [PATCH 21/34] refactor: update permissions for post admin role and remove unused comment roles --- source/templates/blog-access.tf | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index 900351cb..e2089168 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -67,7 +67,7 @@ resource "permitio_role" "post__admin" { key = "admin" name = "admin" resource = permitio_resource.post.key - permissions = ["delete"] + permissions = ["update", "delete", "create", "read"] depends_on = [permitio_resource.post] } @@ -87,22 +87,6 @@ resource "permitio_role" "comment__editor" { depends_on = [permitio_resource.comment] } -resource "permitio_role" "comment__reader" { - key = "reader" - name = "reader" - resource = permitio_resource.comment.key - permissions = ["read", "create"] - - depends_on = [permitio_resource.comment] -} -resource "permitio_role" "comment__admin" { - key = "admin" - name = "admin" - resource = permitio_resource.comment.key - permissions = ["read", "update", "create", "delete"] - - depends_on = [permitio_resource.comment] -} resource "permitio_role" "reader" { key = "reader" name = "reader" @@ -147,16 +131,16 @@ resource "permitio_resource_set" "premium" { } # Role Derivations -resource "permitio_role_derivation" "post_admin_to_comment_admin" { - role = permitio_role.post__admin.key +resource "permitio_role_derivation" "post_editor_to_comment_editor" { + role = permitio_role.post__editor.key on_resource = permitio_resource.post.key - to_role = permitio_role.comment__admin.key + to_role = permitio_role.comment__editor.key resource = permitio_resource.comment.key linked_by = permitio_relation.post_comment.key depends_on = [ - permitio_role.post__admin, + permitio_role.post__editor, permitio_resource.post, - permitio_role.comment__admin, + permitio_role.comment__editor, permitio_resource.comment, permitio_relation.post_comment ] From c719f69d8d86a3ed16df2be4a50a7cac9c239221 Mon Sep 17 00:00:00 2001 From: Pradumna Saraf Date: Sun, 22 Jun 2025 19:31:21 +0530 Subject: [PATCH 22/34] refactor: formatting --- source/templates/blog-access.tf | 54 ++++++++++++++++----------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index e2089168..3b5e5d8d 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -13,10 +13,10 @@ provider "permitio" { } # Resources -resource "permitio_resource" "post" { - name = "post" - description = "resource representing a post entity and its access actions" - key = "post" +resource "permitio_resource" "comment" { + name = "comment" + description = "resource for managing access to post comments" + key = "comment" actions = { "create" = { @@ -25,44 +25,52 @@ resource "permitio_resource" "post" { "update" = { name = "update" }, - "read" = { - name = "read" - }, "delete" = { name = "delete" + }, + "read" = { + name = "read" } } attributes = { - "premium" = { - name = "Premium" - type = "string" - } } } -resource "permitio_resource" "comment" { - name = "comment" - description = "resource for managing access to post comments" - key = "comment" +resource "permitio_resource" "post" { + name = "post" + description = "resource representing a post entity and its access actions" + key = "post" actions = { "delete" = { name = "delete" }, - "read" = { - name = "read" - }, "create" = { name = "create" }, "update" = { name = "update" + }, + "read" = { + name = "read" } } attributes = { + "premium" = { + name = "Premium" + type = "string" + } } } # Roles +resource "permitio_role" "comment__editor" { + key = "editor" + name = "editor" + resource = permitio_resource.comment.key + permissions = ["update", "create", "read"] + + depends_on = [permitio_resource.comment] +} resource "permitio_role" "post__admin" { key = "admin" name = "admin" @@ -75,18 +83,10 @@ resource "permitio_role" "post__editor" { key = "editor" name = "editor" resource = permitio_resource.post.key - permissions = ["update", "read", "create"] + permissions = ["create", "read", "update"] depends_on = [permitio_resource.post] } -resource "permitio_role" "comment__editor" { - key = "editor" - name = "editor" - resource = permitio_resource.comment.key - permissions = ["update", "create", "read"] - - depends_on = [permitio_resource.comment] -} resource "permitio_role" "reader" { key = "reader" name = "reader" From b5a1a456eef01863251355b0f65e1e07b540f9e9 Mon Sep 17 00:00:00 2001 From: orweis Date: Tue, 16 Sep 2025 17:04:38 +0300 Subject: [PATCH 23/34] fix attributes assigned under wrong key Signed-off-by: orweis --- source/components/pdp/PDPCheckComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/components/pdp/PDPCheckComponent.tsx b/source/components/pdp/PDPCheckComponent.tsx index c48ad104..463635f9 100644 --- a/source/components/pdp/PDPCheckComponent.tsx +++ b/source/components/pdp/PDPCheckComponent.tsx @@ -43,7 +43,7 @@ export default function PDPCheckComponent({ options }: PDPCheckProps) { ? options.resource.split(':')[1] : '', tenant: options.tenant, - ...resourceAttrs, + attributes: resourceAttrs, }, action: options.action, }; From c376723275cde7005f33e37988aa7ed42032c13b Mon Sep 17 00:00:00 2001 From: Gabriel Manor Date: Tue, 16 Sep 2025 09:32:10 -0700 Subject: [PATCH 24/34] New version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b31c69f3..e0f002d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@permitio/cli", - "version": "0.2.7", + "version": "0.2.8", "description": "Permit CLI is an open-source command-line tool that empowers developers to manage, test, and automate fine-grained access control across applications.", "license": "MIT", "bin": { From 1feca85fb89bd90a6f53301ca46b67bc9921a53a Mon Sep 17 00:00:00 2001 From: Eli Moshkovich Date: Wed, 15 Oct 2025 09:47:11 -0700 Subject: [PATCH 25/34] Adding support for EU region (#137) * Adding support for EU region * Tests added * Fix OAuth Login Bug, API Key Validation Bug, Terraform Export Bug, Infinite Loop Bug with policy create simple, Missing Actions Bug * Default roles added for exporter --- .../env/export/generators/RoleGenerator.ts | 17 +- source/commands/env/export/utils.ts | 3 +- source/commands/login.tsx | 25 +- source/components/AuthProvider.tsx | 12 +- .../components/policy/CreateSimpleWizard.tsx | 28 ++- .../policy/create/TerraformGenerator.tsx | 3 +- source/config.ts | 92 +++++++- source/hooks/export/PermitSDK.ts | 2 + source/hooks/useClient.ts | 16 +- source/hooks/useParseActions.ts | 101 +++++---- source/hooks/useParseResources.ts | 83 +++---- source/hooks/useParseRoles.ts | 87 +++---- source/lib/api.ts | 6 +- source/lib/auth.ts | 36 ++- source/lib/env/template/utils.ts | 9 +- source/templates/blog-access.tf | 2 +- source/templates/blogging-platform.tf | 2 +- source/templates/fga-tradeoffs.tf | 2 +- source/templates/mesa-verde-banking-demo.tf | 2 +- source/templates/orm-data-filtering.tf | 2 +- tests/EnvCopy.test.tsx | 1 + tests/EnvSelect.test.tsx | 1 + tests/export/RoleGenerator.test.tsx | 27 ++- tests/lib/auth-oauth-region.test.ts | 213 ++++++++++++++++++ tests/lib/auth.test.ts | 38 +++- tests/lib/client-region.test.ts | 140 ++++++++++++ tests/lib/config.test.ts | 212 +++++++++++++++++ 27 files changed, 964 insertions(+), 198 deletions(-) create mode 100644 tests/lib/auth-oauth-region.test.ts create mode 100644 tests/lib/client-region.test.ts create mode 100644 tests/lib/config.test.ts diff --git a/source/commands/env/export/generators/RoleGenerator.ts b/source/commands/env/export/generators/RoleGenerator.ts index 9a1d3e8f..dc9d87eb 100644 --- a/source/commands/env/export/generators/RoleGenerator.ts +++ b/source/commands/env/export/generators/RoleGenerator.ts @@ -8,10 +8,6 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Define default global roles that should be excluded from creation -// These roles already exist in Permit by default -const DEFAULT_GLOBAL_ROLES = ['admin', 'editor', 'viewer']; - interface RoleData { key: string; terraformId: string; @@ -183,10 +179,8 @@ export class RoleGenerator implements HCLGenerator { ): string { let terraformId = roleKey; - const isDefaultRole = DEFAULT_GLOBAL_ROLES.includes(roleKey); - - // For duplicate roles or default roles, use resource__role format - if (isDuplicate || isDefaultRole || this.usedTerraformIds.has(roleKey)) { + // For duplicate roles, use resource__role format + if (isDuplicate || this.usedTerraformIds.has(roleKey)) { terraformId = resourceKey ? `${resourceKey}__${roleKey}` : `global_${roleKey}`; @@ -300,13 +294,6 @@ export class RoleGenerator implements HCLGenerator { const validRoles: RoleData[] = []; for (const role of roles) { - // Skip default global roles that already exist in the system - if (DEFAULT_GLOBAL_ROLES.includes(role.key)) { - // Still add to the ID map for role derivations - this.roleIdMap.set(role.key, role.key); - continue; - } - // Generate Terraform ID const terraformId = this.generateTerraformId(role.key); diff --git a/source/commands/env/export/utils.ts b/source/commands/env/export/utils.ts index b2169660..825823ec 100644 --- a/source/commands/env/export/utils.ts +++ b/source/commands/env/export/utils.ts @@ -1,4 +1,5 @@ import { WarningCollector } from './types.js'; +import { getPermitApiUrl } from '../../../config.js'; export function createSafeId(...parts: string[]): string { return parts @@ -36,7 +37,7 @@ variable "PERMIT_API_KEY" { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = "${getPermitApiUrl()}" api_key = var.PERMIT_API_KEY } `; diff --git a/source/commands/login.tsx b/source/commands/login.tsx index 92c8d4ec..e759f075 100644 --- a/source/commands/login.tsx +++ b/source/commands/login.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Text } from 'ink'; import { type infer as zInfer, object, string } from 'zod'; import { option } from 'pastel'; -import { saveAuthToken } from '../lib/auth.js'; +import { saveAuthToken, saveRegion } from '../lib/auth.js'; +import { setRegion } from '../config.js'; import LoginFlow from '../components/LoginFlow.js'; import EnvironmentSelection, { ActiveState, @@ -25,6 +26,14 @@ export const options = object({ description: 'Use predefined workspace to Login', }), ), + region: string() + .optional() + .describe( + option({ + description: 'Permit region: us or eu (default: us)', + alias: 'r', + }), + ), }); type Props = { @@ -38,9 +47,14 @@ type Props = { }; export default function Login({ - options: { apiKey, workspace }, + options: { apiKey, workspace, region }, loginSuccess, }: Props) { + // Set region IMMEDIATELY before anything else (synchronously) + if (region && (region === 'us' || region === 'eu')) { + setRegion(region as 'us' | 'eu'); + } + const [state, setState] = useState<'login' | 'signup' | 'env' | 'done'>( 'login', ); @@ -51,6 +65,13 @@ export default function Login({ const [organization, setOrganization] = useState(''); const [environment, setEnvironment] = useState(''); + // Save region to keystore after successful login + useEffect(() => { + if (region && (region === 'us' || region === 'eu')) { + saveRegion(region as 'us' | 'eu'); + } + }, [region]); + const onEnvironmentSelectSuccess = useCallback( async ( organisation: ActiveState, diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx index 4d8a03c0..aa06ea06 100644 --- a/source/components/AuthProvider.tsx +++ b/source/components/AuthProvider.tsx @@ -13,7 +13,7 @@ import React, { useState, } from 'react'; import { Text, Newline } from 'ink'; -import { loadAuthToken } from '../lib/auth.js'; +import { loadAuthToken, loadRegion } from '../lib/auth.js'; import Login from '../commands/login.js'; import { ApiKeyCreate, @@ -131,6 +131,11 @@ export function AuthProvider({ redirect_scope: 'organization' | 'project' | 'login', ) => { try { + // Load region from storage BEFORE validating API key + await loadRegion().catch(() => { + // Ignore errors - will default to 'us' + }); + const token = await loadAuthToken(); const { valid, @@ -188,6 +193,11 @@ export function AuthProvider({ useEffect(() => { if (state === 'validate') { (async () => { + // Load region from storage BEFORE validating API key + await loadRegion().catch(() => { + // Ignore errors - will default to 'us' + }); + const { valid, scope: keyScope, diff --git a/source/components/policy/CreateSimpleWizard.tsx b/source/components/policy/CreateSimpleWizard.tsx index 1e0b14cc..c6683740 100644 --- a/source/components/policy/CreateSimpleWizard.tsx +++ b/source/components/policy/CreateSimpleWizard.tsx @@ -30,6 +30,10 @@ export default function CreateSimpleWizard({ const parsedActions = useParseActions(presentActions); const parsedRoles = useParseRoles(presentRoles); + // Track if preset data has been processed + const [hasProcessedPresetData, setHasProcessedPresetData] = + React.useState(false); + // Initialize step based on preset values const getInitialStep = () => { if (presentResources && presentActions && presentRoles) return 'complete'; @@ -109,10 +113,13 @@ export default function CreateSimpleWizard({ }; const handleRolesComplete = useCallback( - async (roles: components['schemas']['RoleCreate'][]) => { + async ( + roles: components['schemas']['RoleCreate'][], + resourcesToCreate?: components['schemas']['ResourceCreate'][], + ) => { setStatus('processing'); try { - await createBulkResources(resources); + await createBulkResources(resourcesToCreate || resources); await createBulkRoles(roles); setStatus('success'); setResources([]); @@ -125,6 +132,9 @@ export default function CreateSimpleWizard({ ); useEffect(() => { + // Only process preset data once + if (hasProcessedPresetData) return; + const processPresetData = async () => { if (presentResources && presentActions && presentRoles) { try { @@ -133,7 +143,8 @@ export default function CreateSimpleWizard({ actions: parsedActions, })); setResources(resourcesWithActions); - await handleRolesComplete(parsedRoles); + setHasProcessedPresetData(true); + await handleRolesComplete(parsedRoles, resourcesWithActions); } catch (err) { handleError((err as Error).message); } @@ -143,10 +154,17 @@ export default function CreateSimpleWizard({ actions: parsedActions, })); setResources(resourcesWithActions); + setHasProcessedPresetData(true); } }; - processPresetData(); + if ( + (presentResources && presentActions && presentRoles) || + (presentResources && presentActions) + ) { + processPresetData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ presentResources, presentActions, @@ -154,8 +172,6 @@ export default function CreateSimpleWizard({ parsedResources, parsedActions, parsedRoles, - handleRolesComplete, - handleError, ]); return ( diff --git a/source/components/policy/create/TerraformGenerator.tsx b/source/components/policy/create/TerraformGenerator.tsx index ea1c9e3d..85b0ba33 100644 --- a/source/components/policy/create/TerraformGenerator.tsx +++ b/source/components/policy/create/TerraformGenerator.tsx @@ -1,4 +1,5 @@ import { PolicyData } from './types.js'; +import { getPermitApiUrl } from '../../../config.js'; interface TerraformGeneratorProps { tableData: PolicyData; @@ -37,7 +38,7 @@ export const generateTerraform = ({ } provider "permitio" { - api_url = "https://api.permit.io" + api_url = "${getPermitApiUrl()}" api_key = "${authToken}" } diff --git a/source/config.ts b/source/config.ts index 8bfc121f..a019f0f9 100644 --- a/source/config.ts +++ b/source/config.ts @@ -1,21 +1,93 @@ export const KEY_FILE_PATH = './permit.key'; export const KEYSTORE_PERMIT_SERVICE_NAME = 'Permit.io'; export const DEFAULT_PERMIT_KEYSTORE_ACCOUNT = 'PERMIT_DEFAULT_ENV'; -export const CLOUD_PDP_URL = 'https://cloudpdp.api.permit.io'; -export const PERMIT_API_URL = 'https://api.permit.io'; -export const PERMIT_API_STATISTICS_URL = - 'https://pdp-statistics.api.permit.io/v2/stats'; -export const API_URL = 'https://api.permit.io/v2/'; -export const FACTS_API_URL = `${API_URL}facts/`; -export const API_PDPS_CONFIG_URL = `${API_URL}pdps/me/config`; -export const PERMIT_ORIGIN_URL = 'https://app.permit.io'; +export const REGION_KEYSTORE_ACCOUNT = 'PERMIT_REGION'; + +// Region type +export type PermitRegion = 'us' | 'eu'; + +// Get region from environment variable or default to 'us' +let currentRegion: PermitRegion = + (process.env['PERMIT_REGION'] as PermitRegion) || 'us'; + +// Function to set the current region +export const setRegion = (region: PermitRegion) => { + currentRegion = region; +}; + +// Function to get the current region +export const getRegion = (): PermitRegion => { + return currentRegion; +}; + +// Function to get region-specific subdomain +const getRegionSubdomain = (region: PermitRegion): string => { + return region === 'eu' ? 'eu.' : ''; +}; + +// Region-aware URL getters +export const getPermitApiUrl = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://api.${subdomain}permit.io`; +}; + +export const getPermitOriginUrl = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://app.${subdomain}permit.io`; +}; + +export const getAuthPermitDomain = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `app.${subdomain}permit.io`; +}; + +export const getCloudPdpUrl = (): string => { + if (currentRegion === 'eu') { + return 'https://cloudpdp.api.eu-central-1.permit.io'; + } + return 'https://cloudpdp.api.permit.io'; +}; + +export const getPermitApiStatisticsUrl = (): string => { + if (currentRegion === 'eu') { + return 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats'; + } + return 'https://pdp-statistics.api.permit.io/v2/stats'; +}; + +export const getApiUrl = (): string => { + return `${getPermitApiUrl()}/v2/`; +}; + +export const getFactsApiUrl = (): string => { + return `${getApiUrl()}facts/`; +}; + +export const getApiPdpsConfigUrl = (): string => { + return `${getApiUrl()}pdps/me/config`; +}; + +export const getAuthApiUrl = (): string => { + return `${getPermitApiUrl()}/v1/`; +}; + +// Legacy exports (maintain backwards compatibility) +export const CLOUD_PDP_URL = getCloudPdpUrl(); +export const PERMIT_API_URL = getPermitApiUrl(); +export const PERMIT_API_STATISTICS_URL = getPermitApiStatisticsUrl(); +export const API_URL = getApiUrl(); +export const FACTS_API_URL = getFactsApiUrl(); +export const API_PDPS_CONFIG_URL = getApiPdpsConfigUrl(); +export const PERMIT_ORIGIN_URL = getPermitOriginUrl(); +export const AUTH_PERMIT_DOMAIN = getAuthPermitDomain(); +export const AUTH_API_URL = getAuthApiUrl(); export const AUTH_REDIRECT_HOST = 'localhost'; export const AUTH_REDIRECT_PORT = 62419; export const AUTH_REDIRECT_URI = `http://${AUTH_REDIRECT_HOST}:${AUTH_REDIRECT_PORT}`; -export const AUTH_PERMIT_DOMAIN = 'app.permit.io'; -export const AUTH_API_URL = 'https://api.permit.io/v1/'; +// auth.permit.io is common for both regions export const AUTH_PERMIT_URL = 'https://auth.permit.io'; +export const AUTH0_AUDIENCE = 'https://api.permit.io/v1/'; // Auth0 audience is shared across all regions export const TERRAFORM_PERMIT_URL = 'https://permit-cli-terraform.up.railway.app'; diff --git a/source/hooks/export/PermitSDK.ts b/source/hooks/export/PermitSDK.ts index ec4badc3..3d855d07 100644 --- a/source/hooks/export/PermitSDK.ts +++ b/source/hooks/export/PermitSDK.ts @@ -1,5 +1,6 @@ import { Permit } from 'permitio'; import React from 'react'; +import { getPermitApiUrl } from '../../config.js'; export const usePermitSDK = ( token: string, @@ -10,6 +11,7 @@ export const usePermitSDK = ( new Permit({ token, pdp: pdpUrl, + apiUrl: getPermitApiUrl(), }), [token, pdpUrl], ); diff --git a/source/hooks/useClient.ts b/source/hooks/useClient.ts index 27790a95..9d52299d 100644 --- a/source/hooks/useClient.ts +++ b/source/hooks/useClient.ts @@ -1,5 +1,9 @@ import type { paths } from '../lib/api/v1.js'; -import { CLOUD_PDP_URL, PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js'; +import { + getCloudPdpUrl, + getPermitApiUrl, + getPermitOriginUrl, +} from '../config.js'; import type { paths as PdpPaths } from '../lib/api/pdp-v1.js'; import createClient, { @@ -308,10 +312,10 @@ const useClient = () => { const authenticatedApiClient = useCallback(() => { const client = createClient({ - baseUrl: PERMIT_API_URL, + baseUrl: getPermitApiUrl(), headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), 'Content-Type': 'application/json', Authorization: `Bearer ${globalTokenGetterSetter.tokenGetter()}`, }, @@ -321,7 +325,7 @@ const useClient = () => { const authenticatedPdpClient = useCallback((pdp_url?: string) => { const client = createClient({ - baseUrl: pdp_url ?? CLOUD_PDP_URL, + baseUrl: pdp_url ?? getCloudPdpUrl(), headers: { Accept: '*/*', 'Content-Type': 'application/json', @@ -503,10 +507,10 @@ const useClient = () => { cookie?: string | null, ) => { const client = createClient({ - baseUrl: PERMIT_API_URL, + baseUrl: getPermitApiUrl(), headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, Cookie: cookie, diff --git a/source/hooks/useParseActions.ts b/source/hooks/useParseActions.ts index 85d0ee77..ded8f83c 100644 --- a/source/hooks/useParseActions.ts +++ b/source/hooks/useParseActions.ts @@ -1,54 +1,59 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; export function useParseActions( actionStrings?: string[], ): Record { - if (!actionStrings || actionStrings.length === 0) return {}; - - try { - return actionStrings.reduce( - (acc, action) => { - // Split action definition into main part and attributes part - const [mainPart, attributesPart] = action.split('@').map(s => s.trim()); - - if (!mainPart) { - throw new Error('Invalid action format'); - } - - // Split main part into key and description - const [key, description] = mainPart.split(':').map(s => s.trim()); - - if (!key) { - throw new Error(`Invalid action key in: ${action}`); - } - - // Process attributes if they exist - const attributes = attributesPart - ? attributesPart.split(',').reduce( - (attrAcc, attr) => { - const attrKey = attr.trim(); - if (attrKey) { - attrAcc[attrKey] = {} as never; - } - return attrAcc; - }, - {} as Record, - ) - : undefined; - - acc[key] = { - name: key, - description: description || undefined, - attributes: attributes, - }; - - return acc; - }, - {} as Record, - ); - } catch (err) { - throw new Error( - `Invalid action format. Expected ["key:description@attribute1,attribute2"], got ${JSON.stringify(actionStrings) + err}`, - ); - } + return useMemo(() => { + if (!actionStrings || actionStrings.length === 0) return {}; + + try { + return actionStrings.reduce( + (acc, action) => { + // Split action definition into main part and attributes part + const [mainPart, attributesPart] = action + .split('@') + .map(s => s.trim()); + + if (!mainPart) { + throw new Error('Invalid action format'); + } + + // Split main part into key and description + const [key, description] = mainPart.split(':').map(s => s.trim()); + + if (!key) { + throw new Error(`Invalid action key in: ${action}`); + } + + // Process attributes if they exist + const attributes = attributesPart + ? attributesPart.split(',').reduce( + (attrAcc, attr) => { + const attrKey = attr.trim(); + if (attrKey) { + attrAcc[attrKey] = {} as never; + } + return attrAcc; + }, + {} as Record, + ) + : undefined; + + acc[key] = { + name: key, + description: description || undefined, + attributes: attributes, + }; + + return acc; + }, + {} as Record, + ); + } catch (err) { + throw new Error( + `Invalid action format. Expected ["key:description@attribute1,attribute2"], got ${JSON.stringify(actionStrings) + err}`, + ); + } + }, [actionStrings]); } diff --git a/source/hooks/useParseResources.ts b/source/hooks/useParseResources.ts index 16d33532..d7bdc556 100644 --- a/source/hooks/useParseResources.ts +++ b/source/hooks/useParseResources.ts @@ -1,51 +1,56 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; export function useParseResources( resourceStrings?: string[], ): components['schemas']['ResourceCreate'][] { - if (!resourceStrings || resourceStrings.length === 0) return []; + return useMemo(() => { + if (!resourceStrings || resourceStrings.length === 0) return []; - try { - return resourceStrings.map(resource => { - // Split resource definition into key and attributes - const [mainPart, attributesPart] = resource.split('@').map(s => s.trim()); + try { + return resourceStrings.map(resource => { + // Split resource definition into key and attributes + const [mainPart, attributesPart] = resource + .split('@') + .map(s => s.trim()); - if (!mainPart) { - throw new Error('Invalid resource format'); - } + if (!mainPart) { + throw new Error('Invalid resource format'); + } - // Split main part into key and name/description - const [key, name] = mainPart.split(':').map(s => s.trim()); + // Split main part into key and name/description + const [key, name] = mainPart.split(':').map(s => s.trim()); - if (!key) { - throw new Error(`Invalid resource key in: ${resource}`); - } + if (!key) { + throw new Error(`Invalid resource key in: ${resource}`); + } - // Process attributes if they exist - const attributes = attributesPart - ? attributesPart.split(',').reduce( - (acc, attr) => { - const attrKey = attr.trim(); - if (attrKey) { - acc[attrKey] = {} as never; - } - return acc; - }, - {} as Record, - ) - : undefined; + // Process attributes if they exist + const attributes = attributesPart + ? attributesPart.split(',').reduce( + (acc, attr) => { + const attrKey = attr.trim(); + if (attrKey) { + acc[attrKey] = {} as never; + } + return acc; + }, + {} as Record, + ) + : undefined; - return { - key, - name: name || key, - description: name || undefined, - attributes, - actions: {}, - }; - }); - } catch (err) { - throw new Error( - `Invalid resource format. Expected ["key:name@attribute1,attribute2"], got ${JSON.stringify(resourceStrings) + err}`, - ); - } + return { + key, + name: name || key, + description: name || undefined, + attributes, + actions: {}, + }; + }); + } catch (err) { + throw new Error( + `Invalid resource format. Expected ["key:name@attribute1,attribute2"], got ${JSON.stringify(resourceStrings) + err}`, + ); + } + }, [resourceStrings]); } diff --git a/source/hooks/useParseRoles.ts b/source/hooks/useParseRoles.ts index bddd2c9d..a79f8965 100644 --- a/source/hooks/useParseRoles.ts +++ b/source/hooks/useParseRoles.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; /** @@ -9,52 +10,56 @@ export function useParseRoles( roleStrings?: string[], availableActions?: string[], ): components['schemas']['RoleCreate'][] { - if (!roleStrings || roleStrings.length === 0) return []; + return useMemo(() => { + if (!roleStrings || roleStrings.length === 0) return []; - try { - return roleStrings.map(roleStr => { - const trimmed = roleStr.trim(); - if (!trimmed) throw new Error('Invalid role format'); + try { + return roleStrings.map(roleStr => { + const trimmed = roleStr.trim(); + if (!trimmed) throw new Error('Invalid role format'); - const [roleKey, ...permParts] = trimmed.split('|').map(s => s.trim()); - if (!roleKey || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(roleKey)) { - throw new Error(`Invalid role key in: ${roleStr}`); - } - if (permParts.length === 0) { - throw new Error( - `Role must have at least one resource or resource:action in: ${roleStr}`, - ); - } + const [roleKey, ...permParts] = trimmed.split('|').map(s => s.trim()); + if (!roleKey || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(roleKey)) { + throw new Error(`Invalid role key in: ${roleStr}`); + } + if (permParts.length === 0) { + throw new Error( + `Role must have at least one resource or resource:action in: ${roleStr}`, + ); + } - const permissions: string[] = []; - for (const perm of permParts) { - if (!perm) continue; - const [resource, action] = perm.split(':').map(s => s.trim()); - if (!resource) - throw new Error(`Invalid resource in permission: ${perm}`); - if (!action) { - // Expand to all actions if availableActions is provided - if (availableActions && availableActions.length > 0) { - permissions.push(...availableActions.map(a => `${resource}:${a}`)); + const permissions: string[] = []; + for (const perm of permParts) { + if (!perm) continue; + const [resource, action] = perm.split(':').map(s => s.trim()); + if (!resource) + throw new Error(`Invalid resource in permission: ${perm}`); + if (!action) { + // Expand to all actions if availableActions is provided + if (availableActions && availableActions.length > 0) { + permissions.push( + ...availableActions.map(a => `${resource}:${a}`), + ); + } else { + permissions.push(resource); // fallback: just resource + } } else { - permissions.push(resource); // fallback: just resource + permissions.push(`${resource}:${action}`); } - } else { - permissions.push(`${resource}:${action}`); } - } - return { - key: roleKey, - name: roleKey, - permissions, - }; - }); - } catch (err) { - throw new Error( - `Invalid role format. Expected ["role|resource:action|resource:action"], got ${JSON.stringify( - roleStrings, - )}. ${err instanceof Error ? err.message : err}`, - ); - } + return { + key: roleKey, + name: roleKey, + permissions, + }; + }); + } catch (err) { + throw new Error( + `Invalid role format. Expected ["role|resource:action|resource:action"], got ${JSON.stringify( + roleStrings, + )}. ${err instanceof Error ? err.message : err}`, + ); + } + }, [roleStrings, availableActions]); } diff --git a/source/lib/api.ts b/source/lib/api.ts index 9f4759f4..b1509447 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,4 +1,4 @@ -import { PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js'; +import { getPermitApiUrl, getPermitOriginUrl } from '../config.js'; type ApiResponse = { headers: Headers; @@ -26,7 +26,7 @@ export const apiCall = async ( method, headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), Authorization: `Bearer ${token}`, Cookie: cookie ?? '', 'Content-Type': 'application/json', @@ -38,7 +38,7 @@ export const apiCall = async ( } try { - const res = await fetch(`${PERMIT_API_URL}/${endpoint}`, options); + const res = await fetch(`${getPermitApiUrl()}/${endpoint}`, options); if (!res.ok) { const errorText = await res.json(); diff --git a/source/lib/auth.ts b/source/lib/auth.ts index f1d352e8..b9b4ebcb 100644 --- a/source/lib/auth.ts +++ b/source/lib/auth.ts @@ -3,14 +3,17 @@ import * as http from 'node:http'; import open from 'open'; import * as pkg from 'keytar'; import { - AUTH_API_URL, - AUTH_PERMIT_DOMAIN, AUTH_REDIRECT_HOST, AUTH_REDIRECT_PORT, AUTH_REDIRECT_URI, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, KEYSTORE_PERMIT_SERVICE_NAME, AUTH_PERMIT_URL, + AUTH0_AUDIENCE, + REGION_KEYSTORE_ACCOUNT, + type PermitRegion, + setRegion, + getAuthPermitDomain, } from '../config.js'; import { URL, URLSearchParams } from 'url'; import { setTimeout } from 'timers'; @@ -74,6 +77,26 @@ export const cleanAuthToken = async () => { KEYSTORE_PERMIT_SERVICE_NAME, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, ); + await deletePassword(KEYSTORE_PERMIT_SERVICE_NAME, REGION_KEYSTORE_ACCOUNT); +}; + +export const saveRegion = async (region: PermitRegion): Promise => { + await setPassword( + KEYSTORE_PERMIT_SERVICE_NAME, + REGION_KEYSTORE_ACCOUNT, + region, + ); + setRegion(region); +}; + +export const loadRegion = async (): Promise => { + const region = await getPassword( + KEYSTORE_PERMIT_SERVICE_NAME, + REGION_KEYSTORE_ACCOUNT, + ); + const permitRegion = (region as PermitRegion) || 'us'; + setRegion(permitRegion); + return permitRegion; }; export const authCallbackServer = async (verifier: string): Promise => { @@ -139,14 +162,15 @@ export const browserAuth = async (): Promise => { } const challenge = base64UrlEncode(sha256(verifier)); + const authPermitDomain = getAuthPermitDomain(); const parameters = new URLSearchParams({ - audience: AUTH_API_URL, - screen_hint: AUTH_PERMIT_DOMAIN, - domain: AUTH_PERMIT_DOMAIN, + audience: AUTH0_AUDIENCE, + screen_hint: authPermitDomain, + domain: authPermitDomain, auth0Client: 'eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMS4xMC4yIn0=', isEAP: 'false', response_type: 'code', - fragment: `domain=${AUTH_PERMIT_DOMAIN}`, + fragment: `domain=${authPermitDomain}`, code_challenge: challenge, code_challenge_method: 'S256', client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1', diff --git a/source/lib/env/template/utils.ts b/source/lib/env/template/utils.ts index a3d3a4a6..72e1977c 100644 --- a/source/lib/env/template/utils.ts +++ b/source/lib/env/template/utils.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { TERRAFORM_PERMIT_URL } from '../../../config.js'; +import { TERRAFORM_PERMIT_URL, getPermitApiUrl } from '../../../config.js'; import { exec } from 'child_process'; import { fileURLToPath } from 'url'; @@ -60,10 +60,9 @@ export async function ApplyTemplateLocally( const tempDirPath = path.join(__dirname, tempDir); try { - const tfContent = getFileContent(fileName).replace( - '{{API_KEY}}', - '"' + apiKey + '"', - ); + const tfContent = getFileContent(fileName) + .replace('{{API_KEY}}', '"' + apiKey + '"') + .replace('{{API_URL}}', '"' + getPermitApiUrl() + '"'); const dirPath = path.dirname(TF_File); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index 3b5e5d8d..3a398eb4 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/blogging-platform.tf b/source/templates/blogging-platform.tf index 248b90a8..9aced5e6 100644 --- a/source/templates/blogging-platform.tf +++ b/source/templates/blogging-platform.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/fga-tradeoffs.tf b/source/templates/fga-tradeoffs.tf index 9f7f685e..94581bf8 100644 --- a/source/templates/fga-tradeoffs.tf +++ b/source/templates/fga-tradeoffs.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/mesa-verde-banking-demo.tf b/source/templates/mesa-verde-banking-demo.tf index 59822572..ce9b5d3a 100644 --- a/source/templates/mesa-verde-banking-demo.tf +++ b/source/templates/mesa-verde-banking-demo.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/orm-data-filtering.tf b/source/templates/orm-data-filtering.tf index 4319da97..494f82c8 100644 --- a/source/templates/orm-data-filtering.tf +++ b/source/templates/orm-data-filtering.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/tests/EnvCopy.test.tsx b/tests/EnvCopy.test.tsx index bea4c45a..b2c4eff2 100644 --- a/tests/EnvCopy.test.tsx +++ b/tests/EnvCopy.test.tsx @@ -14,6 +14,7 @@ vi.mock('../source/lib/auth.js', () => ({ browserAuth: vi.fn(), authCallbackServer: vi.fn(), tokenType: vi.fn(), + loadRegion: vi.fn(() => Promise.resolve()), TokenType: { APIToken: 'APIToken', Invalid: 'Invalid', diff --git a/tests/EnvSelect.test.tsx b/tests/EnvSelect.test.tsx index 7808bede..960f6b45 100644 --- a/tests/EnvSelect.test.tsx +++ b/tests/EnvSelect.test.tsx @@ -75,6 +75,7 @@ vi.mock('../source/components/EnvironmentSelection.js', () => ({ vi.mock('../source/lib/auth.js', () => ({ saveAuthToken: vi.fn(), + loadRegion: vi.fn(() => Promise.resolve()), })); beforeEach(() => { diff --git a/tests/export/RoleGenerator.test.tsx b/tests/export/RoleGenerator.test.tsx index f0252130..55f0207b 100644 --- a/tests/export/RoleGenerator.test.tsx +++ b/tests/export/RoleGenerator.test.tsx @@ -107,7 +107,7 @@ describe('RoleGenerator', () => { ); }); - it('filters out default roles', async () => { + it('exports default roles', async () => { const defaultRolesMockPermit = createMockPermit({ resources: [ { @@ -120,9 +120,17 @@ describe('RoleGenerator', () => { }, ], roles: [ - { key: 'admin', name: 'Admin' }, - { key: 'editor', name: 'Editor' }, - { key: 'viewer', name: 'Viewer' }, + { + key: 'admin', + name: 'Admin', + permissions: ['document:read', 'document:write', 'document:delete'], + }, + { + key: 'editor', + name: 'Editor', + permissions: ['document:read', 'document:write'], + }, + { key: 'viewer', name: 'Viewer', permissions: ['document:read'] }, { key: 'custom', name: 'Custom Role' }, ], }); @@ -133,10 +141,15 @@ describe('RoleGenerator', () => { ); const hcl = await generator.generateHCL(); - expect(hcl).not.toContain('resource "permitio_role" "viewer"'); - expect(hcl).not.toContain('resource "permitio_role" "editor"'); - expect(hcl).not.toContain('resource "permitio_role" "admin"'); + // Verify default roles are exported + expect(hcl).toContain('resource "permitio_role" "viewer"'); + expect(hcl).toContain('resource "permitio_role" "editor"'); + expect(hcl).toContain('resource "permitio_role" "admin"'); expect(hcl).toContain('resource "permitio_role" "custom"'); + + // Verify default roles include their actual permissions (they can differ from defaults) + expect(hcl).toContain('document:delete'); // admin has delete permission + expect(hcl).toContain('document:write'); // editor has write permission }); it('handles role dependencies correctly', async () => { diff --git a/tests/lib/auth-oauth-region.test.ts b/tests/lib/auth-oauth-region.test.ts new file mode 100644 index 00000000..440a84d2 --- /dev/null +++ b/tests/lib/auth-oauth-region.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import open from 'open'; + +// Mock dependencies +vi.mock('keytar', () => ({ + default: { + getPassword: vi.fn(), + setPassword: vi.fn(), + deletePassword: vi.fn(), + }, +})); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +vi.mock('node:crypto', () => ({ + randomBytes: vi.fn().mockReturnValue(Buffer.from('mock-verifier')), + createHash: vi.fn().mockImplementation(() => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => Buffer.from('mock-hash')), + })), +})); + +vi.mock('http', () => ({ + createServer: vi.fn().mockReturnValue({ + listen: vi.fn(), + close: vi.fn(), + }), +})); + +import * as auth from '../../source/lib/auth.js'; + +describe('Auth OAuth - Region Support', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('OAuth URL Generation', () => { + it('should open browser with Auth0 URL for US region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('us'); + + await auth.browserAuth(); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining('https://auth.permit.io/authorize'), + ); + }); + + it('should open browser with Auth0 URL for EU region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining('https://auth.permit.io/authorize'), + ); + }); + + it('should use same Auth0 audience for both US and EU regions', async () => { + const { AUTH0_AUDIENCE } = await import('../../source/config.js'); + + // Auth0 audience should be constant + expect(AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); + }); + + it('should include correct domain parameter for US region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('us'); + + await auth.browserAuth(); + + // Check that the URL contains the US domain + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('domain=app.permit.io'); + }); + + it('should include correct domain parameter for EU region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + // Check that the URL contains the EU domain + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('domain=app.eu.permit.io'); + }); + + it('should include shared Auth0 audience in OAuth parameters', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + // Check that the URL contains the shared audience + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain( + 'audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F', + ); + }); + + it('should include screen_hint with correct region domain', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('screen_hint=app.eu.permit.io'); + }); + }); + + describe('OAuth Parameters Consistency', () => { + it('should use consistent parameters across regions except domain', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test US + setRegion('us'); + await auth.browserAuth(); + const usUrl = (open as any).mock.calls[0][0]; + + vi.clearAllMocks(); + + // Test EU + setRegion('eu'); + await auth.browserAuth(); + const euUrl = (open as any).mock.calls[0][0]; + + // Both should have same client_id + expect(usUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); + expect(euUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); + + // Both should have same audience (shared) + expect(usUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + expect(euUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + + // Both should have same redirect_uri + expect(usUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); + expect(euUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); + + // Both should have PKCE parameters + expect(usUrl).toContain('code_challenge_method=S256'); + expect(euUrl).toContain('code_challenge_method=S256'); + }); + + it('should only differ in domain and screen_hint between regions', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test US + setRegion('us'); + await auth.browserAuth(); + const usUrl = (open as any).mock.calls[0][0]; + + vi.clearAllMocks(); + + // Test EU + setRegion('eu'); + await auth.browserAuth(); + const euUrl = (open as any).mock.calls[0][0]; + + // US should have US domain + expect(usUrl).toContain('domain=app.permit.io'); + expect(usUrl).not.toContain('domain=app.eu.permit.io'); + + // EU should have EU domain + expect(euUrl).toContain('domain=app.eu.permit.io'); + expect(euUrl).not.toContain('domain=app.permit.io'); + }); + }); + + describe('Critical Bug Fix Verification', () => { + it('should NOT use region-specific API URL as Auth0 audience (bug fix)', async () => { + const { setRegion } = await import('../../source/config.js'); + + // The bug was using region-specific URL as audience + // Correct behavior: use shared Auth0 audience + setRegion('eu'); + await auth.browserAuth(); + + const url = (open as any).mock.calls[0][0]; + + // Should NOT contain EU-specific API URL as audience + expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); + + // Should contain shared audience + expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + }); + + it('should use shared Auth0 audience even when switching regions', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test multiple region switches + const regions: Array<'us' | 'eu'> = ['us', 'eu', 'us', 'eu']; + + for (const region of regions) { + setRegion(region); + await auth.browserAuth(); + + const url = (open as any).mock.calls[ + (open as any).mock.calls.length - 1 + ][0]; + + // Always use shared audience + expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + + // Never use region-specific API URL + expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); + } + }); + }); +}); diff --git a/tests/lib/auth.test.ts b/tests/lib/auth.test.ts index 9bc12fe3..eefdcfb4 100644 --- a/tests/lib/auth.test.ts +++ b/tests/lib/auth.test.ts @@ -1,12 +1,10 @@ import { describe, vi, it, expect } from 'vitest'; -import * as auth from '../../source/lib/auth'; import * as http from 'http'; import { KEYSTORE_PERMIT_SERVICE_NAME, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, } from '../../source/config'; import open from 'open'; -import * as pkg from 'keytar'; // Mock dependencies vi.mock('http', () => ({ @@ -37,6 +35,9 @@ vi.mock('keytar', () => { return { ...keytar, default: keytar }; }); +import * as auth from '../../source/lib/auth'; +import * as pkg from 'keytar'; + describe('Token Type', () => { it('Should return correct token type', async () => { const demoToken = 'permit_key_'.concat('a'.repeat(97)); @@ -123,3 +124,36 @@ describe('Browser Auth', () => { expect(open).toHaveBeenCalled(); // Ensure the browser opens }); }); + +describe('Region Support in Auth', () => { + it('Should save region to keystore', async () => { + const { setPassword } = pkg; + await auth.saveRegion('eu'); + expect(setPassword).toHaveBeenCalledWith( + 'Permit.io', + 'PERMIT_REGION', + 'eu', + ); + }); + + it('Should load region from keystore', async () => { + const { getPassword } = pkg; + (getPassword as any).mockResolvedValueOnce('eu'); + const region = await auth.loadRegion(); + expect(region).toBe('eu'); + expect(getPassword).toHaveBeenCalledWith('Permit.io', 'PERMIT_REGION'); + }); + + it('Should default to us region when no region is stored', async () => { + const { getPassword } = pkg; + (getPassword as any).mockResolvedValueOnce(null); + const region = await auth.loadRegion(); + expect(region).toBe('us'); + }); + + it('Should clean region when cleaning auth token', async () => { + const { deletePassword } = pkg; + await auth.cleanAuthToken(); + expect(deletePassword).toHaveBeenCalledWith('Permit.io', 'PERMIT_REGION'); + }); +}); diff --git a/tests/lib/client-region.test.ts b/tests/lib/client-region.test.ts new file mode 100644 index 00000000..b361c1e3 --- /dev/null +++ b/tests/lib/client-region.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +let currentRegion: 'us' | 'eu' = 'us'; + +const getRegionSubdomain = (region: 'us' | 'eu'): string => { + return region === 'eu' ? 'eu.' : ''; +}; + +// Mock the config module +vi.mock('../../source/config.js', async () => { + return { + getPermitApiUrl: vi.fn(() => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://api.${subdomain}permit.io`; + }), + getPermitOriginUrl: vi.fn(() => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://app.${subdomain}permit.io`; + }), + getCloudPdpUrl: vi.fn(() => { + if (currentRegion === 'eu') { + return 'https://cloudpdp.api.eu-central-1.permit.io'; + } + return 'https://cloudpdp.api.permit.io'; + }), + setRegion: vi.fn((region: 'us' | 'eu') => { + currentRegion = region; + }), + getRegion: vi.fn(() => currentRegion), + }; +}); + +// Mock React hooks +vi.mock('react', async () => { + const React = await vi.importActual('react'); + return { + ...React, + useCallback: fn => fn, + useMemo: fn => fn(), + }; +}); + +// Mock openapi-fetch +vi.mock('openapi-fetch', () => ({ + default: vi.fn(config => { + return { + baseUrl: config.baseUrl, + headers: config.headers, + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + }; + }), +})); + +describe('useClient - Region Support', () => { + beforeEach(() => { + vi.clearAllMocks(); + currentRegion = 'us'; + }); + + describe('Region-Aware URL Functions', () => { + it('should use correct API URL for US region', async () => { + const { getPermitApiUrl } = await import('../../source/config.js'); + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + }); + + it('should use correct API URL for EU region', async () => { + const { setRegion, getPermitApiUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + }); + + it('should use correct PDP URL for US region', async () => { + const { getCloudPdpUrl } = await import('../../source/config.js'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should use correct PDP URL for EU region', async () => { + const { setRegion, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should use correct Origin URL for US region', async () => { + const { getPermitOriginUrl } = await import('../../source/config.js'); + expect(getPermitOriginUrl()).toBe('https://app.permit.io'); + }); + + it('should use correct Origin URL for EU region', async () => { + const { setRegion, getPermitOriginUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getPermitOriginUrl()).toBe('https://app.eu.permit.io'); + }); + }); + + describe('Region Switching', () => { + it('should update URLs when switching from US to EU', async () => { + const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + + // Start with US + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + + // Switch to EU + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + expect(getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should update URLs when switching from EU to US', async () => { + const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + + // Start with EU + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + + // Switch to US + setRegion('us'); + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + }); +}); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 00000000..1ac42dd5 --- /dev/null +++ b/tests/lib/config.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('Config - Region Support', () => { + // Reset modules before each test to ensure clean state + beforeEach(async () => { + vi.resetModules(); + delete process.env.PERMIT_REGION; + }); + + describe('Region Configuration', () => { + it('should default to US region when no env var is set', async () => { + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('us'); + }); + + it('should use EU region when PERMIT_REGION=eu is set', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('eu'); + }); + + it('should use US region when PERMIT_REGION=us is set', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('us'); + }); + + it('should allow setting region programmatically', async () => { + const config = await import('../../source/config.js'); + config.setRegion('eu'); + expect(config.getRegion()).toBe('eu'); + config.setRegion('us'); + expect(config.getRegion()).toBe('us'); + }); + }); + + describe('US Region URLs', () => { + it('should return correct US API URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + }); + + it('should return correct US origin URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitOriginUrl()).toBe('https://app.permit.io'); + }); + + it('should return correct US auth domain', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getAuthPermitDomain()).toBe('app.permit.io'); + }); + + it('should return correct US PDP URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should return correct US statistics URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiStatisticsUrl()).toBe( + 'https://pdp-statistics.api.permit.io/v2/stats', + ); + }); + }); + + describe('EU Region URLs', () => { + it('should return correct EU API URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); + }); + + it('should return correct EU origin URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitOriginUrl()).toBe('https://app.eu.permit.io'); + }); + + it('should return correct EU auth domain', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getAuthPermitDomain()).toBe('app.eu.permit.io'); + }); + + it('should return correct EU PDP URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should return correct EU statistics URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiStatisticsUrl()).toBe( + 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats', + ); + }); + }); + + describe('Auth0 Configuration', () => { + it('should use same Auth0 audience for all regions', async () => { + // Test US + process.env.PERMIT_REGION = 'us'; + const configUS = await import('../../source/config.js'); + const usAudience = configUS.AUTH0_AUDIENCE; + + vi.resetModules(); + delete process.env.PERMIT_REGION; + + // Test EU + process.env.PERMIT_REGION = 'eu'; + const configEU = await import('../../source/config.js'); + const euAudience = configEU.AUTH0_AUDIENCE; + + expect(usAudience).toBe('https://api.permit.io/v1/'); + expect(euAudience).toBe('https://api.permit.io/v1/'); + expect(usAudience).toBe(euAudience); + }); + + it('should have correct Auth0 audience constant', async () => { + const config = await import('../../source/config.js'); + expect(config.AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); + }); + + it('should have shared auth.permit.io URL', async () => { + const config = await import('../../source/config.js'); + expect(config.AUTH_PERMIT_URL).toBe('https://auth.permit.io'); + }); + }); + + describe('API URL Functions', () => { + it('should return correct API URL for default region', async () => { + const config = await import('../../source/config.js'); + expect(config.getApiUrl()).toBe('https://api.permit.io/v2/'); + }); + + it('should return correct API URL for EU region', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getApiUrl()).toBe('https://api.eu.permit.io/v2/'); + }); + + it('should return correct Facts API URL for US', async () => { + const config = await import('../../source/config.js'); + expect(config.getFactsApiUrl()).toBe('https://api.permit.io/v2/facts/'); + }); + + it('should return correct Facts API URL for EU', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getFactsApiUrl()).toBe( + 'https://api.eu.permit.io/v2/facts/', + ); + }); + + it('should return correct Auth API URL for US', async () => { + const config = await import('../../source/config.js'); + expect(config.getAuthApiUrl()).toBe('https://api.permit.io/v1/'); + }); + + it('should return correct Auth API URL for EU', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getAuthApiUrl()).toBe('https://api.eu.permit.io/v1/'); + }); + }); + + describe('Region Switching', () => { + it('should update URLs when region is changed', async () => { + const config = await import('../../source/config.js'); + + // Start with US + expect(config.getRegion()).toBe('us'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + + // Switch to EU + config.setRegion('eu'); + expect(config.getRegion()).toBe('eu'); + expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); + expect(config.getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + + // Switch back to US + config.setRegion('us'); + expect(config.getRegion()).toBe('us'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should maintain Auth0 audience when switching regions', async () => { + const config = await import('../../source/config.js'); + + const initialAudience = config.AUTH0_AUDIENCE; + config.setRegion('eu'); + const euAudience = config.AUTH0_AUDIENCE; + config.setRegion('us'); + const usAudience = config.AUTH0_AUDIENCE; + + expect(initialAudience).toBe('https://api.permit.io/v1/'); + expect(euAudience).toBe('https://api.permit.io/v1/'); + expect(usAudience).toBe('https://api.permit.io/v1/'); + }); + }); +}); From 7c91c5e5b0c9edbf50b6010c3f684e6bdb1de523 Mon Sep 17 00:00:00 2001 From: Miracle Onyenma Date: Fri, 15 Aug 2025 11:42:00 +0100 Subject: [PATCH 26/34] Create gateway-api-authorization.tf --- source/templates/gateway-api-authorization.tf | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 source/templates/gateway-api-authorization.tf diff --git a/source/templates/gateway-api-authorization.tf b/source/templates/gateway-api-authorization.tf new file mode 100644 index 00000000..950a91ed --- /dev/null +++ b/source/templates/gateway-api-authorization.tf @@ -0,0 +1,234 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.12" + } + } +} + +variable "PERMIT_API_KEY" { + type = string + description = "The API key for the Permit.io API" +} + +provider "permitio" { + api_url = "https://api.permit.io" + api_key = var.PERMIT_API_KEY +} + +# Resources +resource "permitio_resource" "Comment" { + name = "Comment" + description = "" + key = "Comment" + + actions = { + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + } + } + attributes = { + } +} +resource "permitio_resource" "Category" { + name = "Category" + description = "" key = "Category" + + actions = { + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + } + } + attributes = { + } +} +resource "permitio_resource" "Article" { + name = "Article" + description = "" + key = "Article" + + actions = { + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "update" = { + name = "update" + }, + "delete" = { + name = "delete" + }, + "publish" = { + name = "publish" + } + } + attributes = { + "category" = { + name = "Category" + type = "string" + } + } +} + +# User Attributes +resource "permitio_user_attribute" "user_created_at" { + key = "created_at" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_last_active" { + key = "last_active" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_subscription_tier" { + key = "subscription_tier" + type = "string" + description = "" +} + +# Roles +resource "permitio_role" "author" { + key = "author" + name = "author" + permissions = ["Comment:create", "Article:read", "Comment:read", "Comment:update", +"Article:create", "Article:update", "Category:read"] + + depends_on = [permitio_resource.Comment, permitio_resource.Article, permitio_resource.Category] +} + +# Condition Set Rules +resource "permitio_condition_set_rule" "premium_subscribers_regular_articles_Article_read" { + user_set = permitio_user_set.premium_subscribers.key + permission = "Article:read" + resource_set = permitio_resource_set.regular_articles.key + depends_on = [ + permitio_resource_set.regular_articles, + permitio_user_set.premium_subscribers + ] +} +resource "permitio_condition_set_rule" "premium_subscribers_premium_articles_Article_read" { + user_set = permitio_user_set.premium_subscribers.key + permission = "Article:read" + resource_set = permitio_resource_set.premium_articles.key + depends_on = [ + permitio_resource_set.premium_articles, + permitio_user_set.premium_subscribers + ] +} +resource "permitio_condition_set_rule" "free_subscribers_regular_articles_Article_read" { + user_set = permitio_user_set.free_subscribers.key + permission = "Article:read" + resource_set = permitio_resource_set.regular_articles.key + depends_on = [ + permitio_resource_set.regular_articles, + permitio_user_set.free_subscribers + ] +} + +# Resource Sets +resource "permitio_resource_set" "premium_articles" { + name = "Premium Articles" + key = "premium_articles" + resource = permitio_resource.Article.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.category": { + "equals": "premium" + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.Article + ] +} +resource "permitio_resource_set" "regular_articles" { + name = "Regular Articles" + key = "regular_articles" + resource = permitio_resource.Article.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.category": { + "not-equals": "premium" + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.Article + ] +} + +# User Sets +resource "permitio_user_set" "free_subscribers" { + key = "free_subscribers" + name = "Free Subscribers" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.subscription_tier" = { + equals = "free" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_subscription_tier + ] +} +resource "permitio_user_set" "premium_subscribers" { + key = "premium_subscribers" + name = "Premium Subscribers" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.subscription_tier" = { + equals = "premium" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_subscription_tier + ] +} From 912211589726de1559b1ff8ebf8d736394b2013b Mon Sep 17 00:00:00 2001 From: Dan Yishai Date: Mon, 10 Nov 2025 12:39:41 +0200 Subject: [PATCH 27/34] Add createColumnResources option to Trino configuration and update related components (#139) * Add createColumnResources option to Trino configuration and update related components - Introduced `createColumnResources` boolean option in Trino options, defaulting to false. - Updated `mapTrinoSchemaToPermitResources` to conditionally create column resources based on the new option. - Modified `TrinoComponent` to pass `createColumnResources` prop. - Enhanced `PDPRunComponent` to accept a `tag` prop for specifying the PDP image tag. - Adjusted Docker command in `PDPRunComponent` to use the provided tag. - Updated Trino resource naming for better clarity in the mapping function. - Reduced healthcheck interval in Docker Compose for faster feedback during tests. * Fix lint * Refactor Trino resource mapping tests for improved clarity and consistency - Updated resource keys in tests to use underscores instead of hyphens for consistency. - Enhanced test assertions to include resource type prefixes (e.g., 'Catalog:', 'Schema:', 'Table:') for better readability. - Added `createColumnResources` option in the `mapTrinoSchemaToPermitResources` function call to align with recent changes. * Fix lint --- source/commands/env/apply/trino.tsx | 10 ++ source/commands/pdp/run.tsx | 12 +- .../components/env/trino/TrinoComponent.tsx | 4 +- source/components/env/trino/types.ts | 1 + source/components/pdp/PDPRunComponent.tsx | 4 +- source/utils/trinoUtils.ts | 108 ++++++++++-------- tests/trino/docker-compose.yml | 4 +- .../trino_config/access-control.properties | 5 + tests/utils/trinoUtils.test.tsx | 36 +++--- 9 files changed, 112 insertions(+), 72 deletions(-) create mode 100644 tests/trino/trino_config/access-control.properties diff --git a/source/commands/env/apply/trino.tsx b/source/commands/env/apply/trino.tsx index ea1d9db6..9194a728 100644 --- a/source/commands/env/apply/trino.tsx +++ b/source/commands/env/apply/trino.tsx @@ -56,6 +56,16 @@ export const options = zod.object({ alias: 's', }), ), + createColumnResources: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: 'Create individual column resources (default: false)', + alias: 'cols', + }), + ), }); export default function Trino({ options }: { options: TrinoOptions }) { diff --git a/source/commands/pdp/run.tsx b/source/commands/pdp/run.tsx index edb8c1b5..6e01e5d6 100644 --- a/source/commands/pdp/run.tsx +++ b/source/commands/pdp/run.tsx @@ -28,16 +28,24 @@ export const options = object({ alias: 'k', }), ), + tag: string() + .default('latest') + .describe( + option({ + description: 'The tag of the PDP image to use', + alias: 't', + }), + ), }); type Props = { options: zInfer; }; -export default function Run({ options: { opa, dryRun, apiKey } }: Props) { +export default function Run({ options: { opa, dryRun, apiKey, tag } }: Props) { return ( - + ); } diff --git a/source/components/env/trino/TrinoComponent.tsx b/source/components/env/trino/TrinoComponent.tsx index 7b7b2e41..a74a5b49 100644 --- a/source/components/env/trino/TrinoComponent.tsx +++ b/source/components/env/trino/TrinoComponent.tsx @@ -21,7 +21,9 @@ export default function TrinoComponent( catalog: props.catalog, schema: props.schema, }); - const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); + const permitResources = mapTrinoSchemaToPermitResources(trinoSchema, { + createColumnResources: props.createColumnResources, + }); setCreatedResources(permitResources); await processTrinoSchema(props); })(); diff --git a/source/components/env/trino/types.ts b/source/components/env/trino/types.ts index 413099dc..8c59d8dd 100644 --- a/source/components/env/trino/types.ts +++ b/source/components/env/trino/types.ts @@ -5,6 +5,7 @@ export type TrinoOptions = { password?: string; catalog?: string; schema?: string; + createColumnResources?: boolean; }; export interface PermitResource { diff --git a/source/components/pdp/PDPRunComponent.tsx b/source/components/pdp/PDPRunComponent.tsx index c9100d11..a968e315 100644 --- a/source/components/pdp/PDPRunComponent.tsx +++ b/source/components/pdp/PDPRunComponent.tsx @@ -16,6 +16,7 @@ type Props = { onComplete?: () => void; onError?: (error: string) => void; skipWaitScreen?: boolean; // New prop to control wait behavior + tag?: string; }; export default function PDPRunComponent({ @@ -24,6 +25,7 @@ export default function PDPRunComponent({ onComplete, onError, skipWaitScreen = true, // Default to showing wait screen + tag = 'latest', }: Props) { const { authToken } = useAuth(); const [loading, setLoading] = useState(true); @@ -82,7 +84,7 @@ export default function PDPRunComponent({ // Generate the Docker command const cmd = `docker run -d -p 7766:7000 ${ opa ? `-p ${opa}:8181` : '' - } -e PDP_API_KEY=${token} -e PDP_CONTROL_PLANE=${config.controlPlane || 'https://api.permit.io'} permitio/pdp-v2:latest`; + } -e PDP_API_KEY=${token} -e PDP_CONTROL_PLANE=${config.controlPlane || 'https://api.permit.io'} permitio/pdp-v2:${tag}`; setDockerCommand(cmd); diff --git a/source/utils/trinoUtils.ts b/source/utils/trinoUtils.ts index 6a951e4c..ffeaa658 100644 --- a/source/utils/trinoUtils.ts +++ b/source/utils/trinoUtils.ts @@ -105,20 +105,24 @@ export function trinoTypeToPermitType( /** * Map Trino schema data to Permit resources. - * - Each catalog, schema, table, and column is a resource. + * - Each catalog, schema, table, and optionally column is a resource. * - Each table resource includes columns as attributes (with type/description). + * @param trino - The Trino schema data to map + * @param options - Configuration options + * @param options.createColumnResources - Whether to create individual column resources (default: false) */ export function mapTrinoSchemaToPermitResources( trino: TrinoSchemaData, + options: { createColumnResources?: boolean } = {}, ): PermitResource[] { const resources: PermitResource[] = []; - const SEP = '-'; + const SEP = '_'; // Catalogs for (const catalog of trino.catalogs) { resources.push({ key: `trino${SEP}catalog${SEP}${catalog.name}`, - name: catalog.name, + name: `Catalog: ${catalog.name}`, description: `Trino resource type: catalog. Trino catalog: ${catalog.name}`, actions: [ 'AccessCatalog', @@ -133,7 +137,7 @@ export function mapTrinoSchemaToPermitResources( for (const schema of trino.schemas) { resources.push({ key: `trino${SEP}schema${SEP}${schema.catalog}${SEP}${schema.name}`, - name: `${schema.catalog}.${schema.name}`, + name: `Schema: ${schema.catalog}.${schema.name}`, description: `Trino resource type: schema. Schema ${schema.name} in catalog ${schema.catalog}`, actions: [ 'CreateSchema', @@ -175,7 +179,7 @@ export function mapTrinoSchemaToPermitResources( const tableKey = `trino${SEP}table${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}`; resources.push({ key: tableKey, - name: `${table.catalog}.${table.schema}.${table.name}`, + name: `Table: ${table.catalog}.${table.schema}.${table.name}`, description: `Trino resource type: ${table.type.toLowerCase()}. ${table.type} ${table.name} in ${table.catalog}.${table.schema}`, actions: TABLE_AND_COLUMN_ACTIONS, attributes: table.columns.reduce( @@ -202,29 +206,35 @@ export function mapTrinoSchemaToPermitResources( }, ), }); - // Columns as resources - for (const column of table.columns) { - resources.push({ - key: `trino${SEP}column${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}${SEP}${column.name}`, - name: `${table.catalog}.${table.schema}.${table.name}.${column.name}`, - description: `Trino resource type: column. Column ${column.name} in ${table.catalog}.${table.schema}.${table.name}`, - actions: TABLE_AND_COLUMN_ACTIONS, - attributes: { - parent_table: { - type: 'string', - description: `${table.catalog}.${table.schema}.${table.name}`, - }, - table_type: { type: 'string', description: table.type.toLowerCase() }, - type: { - type: trinoTypeToPermitType(column.type), - description: column.type, - }, - nullable: { - type: 'bool', - description: column.nullable ? 'nullable' : undefined, + + // Create column resources if requested + if (options.createColumnResources) { + for (const column of table.columns) { + resources.push({ + key: `trino${SEP}column${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}${SEP}${column.name}`, + name: `Column: ${table.catalog}.${table.schema}.${table.name}.${column.name}`, + description: `Trino resource type: column. Column ${column.name} in ${table.catalog}.${table.schema}.${table.name}`, + actions: TABLE_AND_COLUMN_ACTIONS, + attributes: { + parent_table: { + type: 'string', + description: `${table.catalog}.${table.schema}.${table.name}`, + }, + table_type: { + type: 'string', + description: table.type.toLowerCase(), + }, + type: { + type: trinoTypeToPermitType(column.type), + description: column.type, + }, + nullable: { + type: 'bool', + description: column.nullable ? 'nullable' : undefined, + }, }, - }, - }); + }); + } } } @@ -232,7 +242,7 @@ export function mapTrinoSchemaToPermitResources( for (const view of trino.views) { resources.push({ key: `trino${SEP}view${SEP}${view.catalog}${SEP}${view.schema}${SEP}${view.name}`, - name: `${view.catalog}.${view.schema}.${view.name}`, + name: `View: ${view.catalog}.${view.schema}.${view.name}`, description: `Trino resource type: view. View ${view.name} in ${view.catalog}.${view.schema}`, actions: [ 'CreateView', @@ -272,7 +282,7 @@ export function mapTrinoSchemaToPermitResources( for (const mview of trino.materializedViews) { resources.push({ key: `trino${SEP}materialized_view${SEP}${mview.catalog}${SEP}${mview.schema}${SEP}${mview.name}`, - name: `${mview.catalog}.${mview.schema}.${mview.name}`, + name: `Materialized View: ${mview.catalog}.${mview.schema}.${mview.name}`, description: `Trino resource type: materialized view. Materialized view ${mview.name} in ${mview.catalog}.${mview.schema}`, actions: [ 'CreateMaterializedView', @@ -311,7 +321,7 @@ export function mapTrinoSchemaToPermitResources( for (const fn of trino.functions) { resources.push({ key: `trino${SEP}function${SEP}${fn.catalog}${SEP}${fn.schema}${SEP}${fn.name}`, - name: `${fn.catalog}.${fn.schema}.${fn.name}`, + name: `Function: ${fn.catalog}.${fn.schema}.${fn.name}`, description: `Trino resource type: function. Function ${fn.name} in ${fn.catalog}.${fn.schema}`, actions: [ 'ShowFunctions', @@ -333,7 +343,7 @@ export function mapTrinoSchemaToPermitResources( for (const proc of trino.procedures) { resources.push({ key: `trino${SEP}procedure${SEP}${proc.catalog}${SEP}${proc.schema}${SEP}${proc.name}`, - name: `${proc.catalog}.${proc.schema}.${proc.name}`, + name: `Procedure: ${proc.catalog}.${proc.schema}.${proc.name}`, description: `Trino resource type: procedure. Procedure ${proc.name} in ${proc.catalog}.${proc.schema}`, actions: ['ExecuteProcedure', 'ExecuteTableProcedure'], attributes: { @@ -426,20 +436,20 @@ export async function fetchTrinoFunctionsAndProceduresPassthrough( if (catalog.toLowerCase() === 'postgresql') { const passthrough = `SELECT * FROM TABLE(postgresql.system.query(query => ' - SELECT p.proname as function_name, n.nspname as schema_name, - pg_catalog.pg_get_function_result(p.oid) as return_type, - pg_catalog.pg_get_function_arguments(p.oid) as arguments, - CASE p.prokind - WHEN ''f'' THEN ''FUNCTION'' - WHEN ''p'' THEN ''PROCEDURE'' - WHEN ''a'' THEN ''AGGREGATE'' - WHEN ''w'' THEN ''WINDOW'' - ELSE p.prokind::text - END as kind - FROM pg_catalog.pg_proc p - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = ''${schema}'' - AND p.proname NOT LIKE ''pg_%'' + SELECT p.proname as function_name, n.nspname as schema_name, + pg_catalog.pg_get_function_result(p.oid) as return_type, + pg_catalog.pg_get_function_arguments(p.oid) as arguments, + CASE p.prokind + WHEN ''f'' THEN ''FUNCTION'' + WHEN ''p'' THEN ''PROCEDURE'' + WHEN ''a'' THEN ''AGGREGATE'' + WHEN ''w'' THEN ''WINDOW'' + ELSE p.prokind::text + END as kind + FROM pg_catalog.pg_proc p + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ''${schema}'' + AND p.proname NOT LIKE ''pg_%'' ORDER BY p.proname '))`; try { @@ -472,8 +482,8 @@ export async function fetchTrinoFunctionsAndProceduresPassthrough( } } else if (catalog.toLowerCase() === 'mysql') { const passthrough = `SELECT * FROM TABLE(mysql.system.query(query => ' - SELECT routine_name, routine_type, data_type, routine_definition - FROM information_schema.routines + SELECT routine_name, routine_type, data_type, routine_definition + FROM information_schema.routines WHERE routine_schema = ''${schema}'' '))`; try { @@ -556,8 +566,8 @@ export async function fetchTrinoSchema( const tableComments = new Map(); try { const commentsQuery = ` - SELECT catalog_name, schema_name, table_name, comment - FROM system.metadata.table_comments + SELECT catalog_name, schema_name, table_name, comment + FROM system.metadata.table_comments WHERE comment IS NOT NULL `; const commentRows = await executeTrinoQuery(client, commentsQuery); diff --git a/tests/trino/docker-compose.yml b/tests/trino/docker-compose.yml index e7815389..7e670b7f 100644 --- a/tests/trino/docker-compose.yml +++ b/tests/trino/docker-compose.yml @@ -14,7 +14,7 @@ services: - ./sample_data/postgres_init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ['CMD-SHELL', 'pg_isready -U testuser -d testdb'] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -42,7 +42,7 @@ services: 'testuser', '-ptestpass', ] - interval: 10s + interval: 5s timeout: 5s retries: 5 diff --git a/tests/trino/trino_config/access-control.properties b/tests/trino/trino_config/access-control.properties new file mode 100644 index 00000000..96e1dac8 --- /dev/null +++ b/tests/trino/trino_config/access-control.properties @@ -0,0 +1,5 @@ +# access-control.name=allow-all +access-control.name=opa +opa.policy.uri=http://host.docker.internal:7766/trino/allowed +opa.log-requests=true +opa.log-responses=true diff --git a/tests/utils/trinoUtils.test.tsx b/tests/utils/trinoUtils.test.tsx index 635adba9..fd87550d 100644 --- a/tests/utils/trinoUtils.test.tsx +++ b/tests/utils/trinoUtils.test.tsx @@ -40,28 +40,30 @@ describe('mapTrinoSchemaToPermitResources', () => { materializedViews: [], procedures: [], }; - const resources = mapTrinoSchemaToPermitResources(schema); + const resources = mapTrinoSchemaToPermitResources(schema, { + createColumnResources: true, + }); // Check catalogs - const catalog = resources.find(r => r.key === 'trino-catalog-testcat'); + const catalog = resources.find(r => r.key === 'trino_catalog_testcat'); expect(catalog).toBeDefined(); - expect(catalog?.name).toBe('testcat'); + expect(catalog?.name).toBe('Catalog: testcat'); expect(catalog?.actions).toContain('AccessCatalog'); // Check schemas const schemaResource = resources.find( - r => r.key === 'trino-schema-testcat-public', + r => r.key === 'trino_schema_testcat_public', ); expect(schemaResource).toBeDefined(); - expect(schemaResource?.name).toBe('testcat.public'); + expect(schemaResource?.name).toBe('Schema: testcat.public'); expect(schemaResource?.actions).toContain('CreateSchema'); // Check tables const table = resources.find( - r => r.key === 'trino-table-testcat-public-users', + r => r.key === 'trino_table_testcat_public_users', ); expect(table).toBeDefined(); - expect(table?.name).toBe('testcat.public.users'); + expect(table?.name).toBe('Table: testcat.public.users'); expect(table?.actions).toContain('CreateTable'); expect(table?.attributes).toBeDefined(); expect(table?.attributes?.id).toEqual({ type: 'number' }); @@ -69,10 +71,10 @@ describe('mapTrinoSchemaToPermitResources', () => { // Check columns const column = resources.find( - r => r.key === 'trino-column-testcat-public-users-id', + r => r.key === 'trino_column_testcat_public_users_id', ); expect(column).toBeDefined(); - expect(column?.name).toBe('testcat.public.users.id'); + expect(column?.name).toBe('Column: testcat.public.users.id'); expect(column?.actions).toContain('SelectFromColumns'); }); @@ -123,10 +125,10 @@ describe('mapTrinoSchemaToPermitResources', () => { // Check function const func = resources.find( - r => r.key === 'trino-function-testcat-public-my_func', + r => r.key === 'trino_function_testcat_public_my_func', ); expect(func).toBeDefined(); - expect(func?.name).toBe('testcat.public.my_func'); + expect(func?.name).toBe('Function: testcat.public.my_func'); expect(func?.actions).toContain('ExecuteFunction'); expect(func?.actions).toContain('ShowFunctions'); expect(func?.attributes?.returnType).toEqual({ type: 'number' }); @@ -134,10 +136,10 @@ describe('mapTrinoSchemaToPermitResources', () => { // Check view const view = resources.find( - r => r.key === 'trino-view-testcat-public-my_view', + r => r.key === 'trino_view_testcat_public_my_view', ); expect(view).toBeDefined(); - expect(view?.name).toBe('testcat.public.my_view'); + expect(view?.name).toBe('View: testcat.public.my_view'); expect(view?.actions).toContain('CreateView'); expect(view?.actions).toContain('DropView'); expect(view?.attributes?.col1).toEqual({ type: 'string' }); @@ -148,20 +150,20 @@ describe('mapTrinoSchemaToPermitResources', () => { // Check materialized view const mview = resources.find( - r => r.key === 'trino-materialized_view-testcat-public-my_mview', + r => r.key === 'trino_materialized_view_testcat_public_my_mview', ); expect(mview).toBeDefined(); - expect(mview?.name).toBe('testcat.public.my_mview'); + expect(mview?.name).toBe('Materialized View: testcat.public.my_mview'); expect(mview?.actions).toContain('CreateMaterializedView'); expect(mview?.actions).toContain('RefreshMaterializedView'); expect(mview?.attributes?.total).toEqual({ type: 'number' }); // Check procedure const proc = resources.find( - r => r.key === 'trino-procedure-testcat-public-my_proc', + r => r.key === 'trino_procedure_testcat_public_my_proc', ); expect(proc).toBeDefined(); - expect(proc?.name).toBe('testcat.public.my_proc'); + expect(proc?.name).toBe('Procedure: testcat.public.my_proc'); expect(proc?.actions).toContain('ExecuteProcedure'); expect(proc?.attributes?.argumentTypes).toEqual({ type: 'array' }); }); From 6e6bd15308f7ce5328aa8a6005fb0126db3d4993 Mon Sep 17 00:00:00 2001 From: Eli Moshkovich Date: Mon, 10 Nov 2025 09:08:57 -0600 Subject: [PATCH 28/34] fix the publisher CI (#140) --- .github/workflows/npm-publish.yml | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 9c5f3a7f..12957d83 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -34,6 +34,16 @@ jobs: with: node-version: 20 registry-url: https://registry.npmjs.org/ + - name: Extract version from release tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/} + VERSION=${VERSION#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version: $VERSION" + - name: Update package.json version + run: | + sed -i 's/"version": "0.0.0-placeholder"/"version": "${{ steps.get_version.outputs.version }}"/' package.json - run: npm ci - name: Download build artifacts uses: actions/download-artifact@v4 diff --git a/package.json b/package.json index e0f002d8..bba8a2e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@permitio/cli", - "version": "0.2.8", + "version": "0.0.0-placeholder", "description": "Permit CLI is an open-source command-line tool that empowers developers to manage, test, and automate fine-grained access control across applications.", "license": "MIT", "bin": { From 5ce3e0104377828b82d021b0bbd7350056a16d4f Mon Sep 17 00:00:00 2001 From: Taofiqq Date: Wed, 26 Nov 2025 18:12:28 +0100 Subject: [PATCH 29/34] add expense approval system terraform file --- .../expense-approval-system-policy.tf | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 source/templates/expense-approval-system-policy.tf diff --git a/source/templates/expense-approval-system-policy.tf b/source/templates/expense-approval-system-policy.tf new file mode 100644 index 00000000..ad5e7fcc --- /dev/null +++ b/source/templates/expense-approval-system-policy.tf @@ -0,0 +1,345 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.14" + } + } + } + + provider "permitio" { + api_key = var.permit_api_key # Set via PERMITIO_API_KEY env var + api_url = "https://api.permit.io" + } + + # Variable for API key + variable "permit_api_key" { + description = "Permit.io API Key" + type = string + sensitive = true + } + + # User Attributes + resource "permitio_user_attribute" "spending_limit" { + key = "spending_limit" + description = "Maximum amount user can spend" + type = "number" + } + + resource "permitio_user_attribute" "department" { + key = "department" + description = "User's department" + type = "string" + } + + resource "permitio_user_attribute" "job_level" { + key = "job_level" + description = "User's job level (Junior, Senior, Manager)" + type = "string" + } + + resource "permitio_user_attribute" "approval_limit" { + key = "approval_limit" + description = "Maximum amount user can approve" + type = "number" + } + + # Expense Resource with Attributes + resource "permitio_resource" "expense" { + key = "expense" + name = "Expense" + description = "Employee expense reports" + + actions = { + "submit" = { + name = "Submit" + description = "Submit expense for approval" + } + "approve" = { + name = "Approve" + description = "Approve expense" + } + "view" = { + name = "View" + description = "View expense details" + } + } + + attributes = { + "expense_amount" = { + type = "number" + description = "Amount of the expense" + } + "category" = { + type = "string" + description = "Expense category (travel, meals, supplies, etc)" + } + "submitter_department" = { + type = "string" + description = "Department of expense submitter" + } + "urgency" = { + type = "string" + description = "Urgency level (normal, urgent)" + } + } + } + + # User Set: Regular Employees (can submit within their limit) + resource "permitio_user_set" "regular_employees" { + key = "regular_employees" + name = "Regular Employees" + + conditions = jsonencode({ + "allOf" = [ + { "user.job_level" = { "in" = ["Junior", "Senior"] } } + ] + }) + + depends_on = [ + permitio_user_attribute.job_level + ] + } + + # User Set: Department Managers (can approve expenses in their department) + resource "permitio_user_set" "department_managers" { + key = "department_managers" + name = "Department Managers" + + conditions = jsonencode({ + "allOf" = [ + { "user.job_level" = { "equals" = "Manager" } }, + { "user.approval_limit" = { "greater-than-equals" = { "ref" = "resource.expense_amount" } } }, + { "user.department" = { "equals" = { "ref" = "resource.submitter_department" } } } + ] + }) + + depends_on = [ + permitio_user_attribute.job_level, + permitio_user_attribute.approval_limit, + permitio_user_attribute.department + ] + } + + # User Set: Senior Managers (can approve high-value expenses) + resource "permitio_user_set" "senior_managers" { + key = "senior_managers" + name = "Senior Managers" + + conditions = jsonencode({ + "allOf" = [ + { "user.job_level" = { "equals" = "Senior Manager" } }, + { "user.approval_limit" = { "greater-than-equals" = { "ref" = "resource.expense_amount" } } } + ] + }) + + depends_on = [ + permitio_user_attribute.job_level, + permitio_user_attribute.approval_limit + ] + } + + # User Set: Finance Team (can approve any expense) + resource "permitio_user_set" "finance_team" { + key = "finance_team" + name = "Finance Team" + + conditions = jsonencode({ + "allOf" = [ + { "user.department" = { "equals" = "Finance" } } + ] + }) + + depends_on = [ + permitio_user_attribute.department + ] + } + + # Resource Set: Submittable Expenses (within user's spending limit) + resource "permitio_resource_set" "submittable_expenses" { + key = "submittable_expenses" + name = "Submittable Expenses" + resource = permitio_resource.expense.key + + conditions = jsonencode({ + "allOf" = [ + { "resource.expense_amount" = { "less-than-equals" = { "ref" = "user.spending_limit" } } } + ] + }) + + depends_on = [ + permitio_user_attribute.spending_limit, + permitio_resource.expense + ] + } + + # Resource Set: Department Expenses (standard approval workflow) + resource "permitio_resource_set" "department_expenses" { + key = "department_expenses" + name = "Department Expenses" + resource = permitio_resource.expense.key + + conditions = jsonencode({ + "allOf" = [ + { "resource.expense_amount" = { "less-than" = 5000 } }, + { "resource.urgency" = { "equals" = "normal" } } + ] + }) + + depends_on = [ + permitio_resource.expense + ] + } + + # Resource Set: High-Value Expenses (need senior approval) + resource "permitio_resource_set" "high_value_expenses" { + key = "high_value_expenses" + name = "High Value Expenses" + resource = permitio_resource.expense.key + + conditions = jsonencode({ + "allOf" = [ + { "resource.expense_amount" = { "greater-than-equals" = 5000 } } + ] + }) + + depends_on = [ + permitio_resource.expense + ] + } + + # Resource Set: All Expenses (for finance team) + resource "permitio_resource_set" "all_expenses" { + key = "all_expenses" + name = "All Expenses" + resource = permitio_resource.expense.key + + conditions = jsonencode({ + "allOf" = [ + { "resource.expense_amount" = { "greater-than" = 0 } } + ] + }) + + depends_on = [ + permitio_resource.expense + ] + } + + # Condition Set Rules + + # Rule: Regular employees can submit expenses within their limit + resource "permitio_condition_set_rule" "employee_submit_rule" { + user_set = permitio_user_set.regular_employees.key + resource_set = permitio_resource_set.submittable_expenses.key + permission = "expense:submit" + + depends_on = [ + permitio_user_set.regular_employees, + permitio_resource_set.submittable_expenses + ] + } + + # Rule: Regular employees can view expenses they submitted + resource "permitio_condition_set_rule" "employee_view_rule" { + user_set = permitio_user_set.regular_employees.key + resource_set = permitio_resource_set.submittable_expenses.key + permission = "expense:view" + + depends_on = [ + permitio_user_set.regular_employees, + permitio_resource_set.submittable_expenses + ] + } + + # Rule: Department managers can view department expenses + resource "permitio_condition_set_rule" "manager_view_rule" { + user_set = permitio_user_set.department_managers.key + resource_set = permitio_resource_set.department_expenses.key + permission = "expense:view" + + depends_on = [ + permitio_user_set.department_managers, + permitio_resource_set.department_expenses + ] + } + + # Rule: Department managers can approve department expenses + resource "permitio_condition_set_rule" "manager_approve_rule" { + user_set = permitio_user_set.department_managers.key + resource_set = permitio_resource_set.department_expenses.key + permission = "expense:approve" + + depends_on = [ + permitio_user_set.department_managers, + permitio_resource_set.department_expenses + ] + } + + # Rule: Senior managers can view high-value expenses + resource "permitio_condition_set_rule" "senior_view_rule" { + user_set = permitio_user_set.senior_managers.key + resource_set = permitio_resource_set.high_value_expenses.key + permission = "expense:view" + + depends_on = [ + permitio_user_set.senior_managers, + permitio_resource_set.high_value_expenses + ] + } + + # Rule: Senior managers can approve high-value expenses + resource "permitio_condition_set_rule" "senior_approve_rule" { + user_set = permitio_user_set.senior_managers.key + resource_set = permitio_resource_set.high_value_expenses.key + permission = "expense:approve" + + depends_on = [ + permitio_user_set.senior_managers, + permitio_resource_set.high_value_expenses + ] + } + + # Rule: Finance team can view all expenses + resource "permitio_condition_set_rule" "finance_view_rule" { + user_set = permitio_user_set.finance_team.key + resource_set = permitio_resource_set.all_expenses.key + permission = "expense:view" + + depends_on = [ + permitio_user_set.finance_team, + permitio_resource_set.all_expenses + ] + } + + # Rule: Finance team can approve any expense + resource "permitio_condition_set_rule" "finance_approve_rule" { + user_set = permitio_user_set.finance_team.key + resource_set = permitio_resource_set.all_expenses.key + permission = "expense:approve" + + depends_on = [ + permitio_user_set.finance_team, + permitio_resource_set.all_expenses + ] + } + + # Output the resource configurations for reference + output "expense_workflow_setup" { + value = { + resource_id = permitio_resource.expense.key + user_sets = [ + permitio_user_set.regular_employees.key, + permitio_user_set.department_managers.key, + permitio_user_set.senior_managers.key, + permitio_user_set.finance_team.key + ] + resource_sets = [ + permitio_resource_set.submittable_expenses.key, + permitio_resource_set.department_expenses.key, + permitio_resource_set.high_value_expenses.key, + permitio_resource_set.all_expenses.key + ] + rules_count = 8 + } + description = "Expense workflow ABAC configuration summary" + } From 11351e1bc9f5f8259772a9fb2fd8151c6e7b2a77 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Fri, 5 Dec 2025 15:07:30 +0100 Subject: [PATCH 30/34] Add LMS app template configuration --- source/templates/lms-app/lmsapp.tf | 255 +++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 source/templates/lms-app/lmsapp.tf diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf new file mode 100644 index 00000000..a4c0ef6f --- /dev/null +++ b/source/templates/lms-app/lmsapp.tf @@ -0,0 +1,255 @@ +terraform { + required_providers { + permitio = { + source = "permitio/permit-io" + version = "~> 0.0.14" + } + } +} + +provider "permitio" { + api_url = {{API_URL}} + api_key = {{API_KEY}} +} + +# Resources +resource "permitio_resource" "course" { + name = "course" + description = "" + key = "course" + + actions = { + "enroll" = { + name = "enroll" + }, + "read" = { + name = "read" + }, + "create" = { + name = "create" + }, + "delete" = { + name = "delete" + } + } + attributes = { + "department" = { + name = "Department" + type = "string" + }, + "studentIds" = { + name = "Student Ids" + type = "array" + }, + "teacherId" = { + name = "Teacher Id" + type = "string" + } + } +} + +# User Attributes +resource "permitio_user_attribute" "user_department" { + key = "department" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_id" { + key = "id" + type = "string" + description = "" +} +resource "permitio_user_attribute" "user_role" { + key = "role" + type = "string" + description = "user role" +} + +# Roles + +# Condition Set Rules +resource "permitio_condition_set_rule" "student_Courses_Where_Student_is_Enrolled_and_Same_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department, + permitio_user_set.student + ] +} +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.teacher.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher + ] +} +resource "permitio_condition_set_rule" "student_Courses_Matching_Teacher_Department_course_read" { + user_set = permitio_user_set.student.key + permission = "course:read" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.student + ] +} +resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_create" { + user_set = permitio_user_set.teacher.key + permission = "course:create" + resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key + depends_on = [ + permitio_resource_set.Courses_Matching_Teacher_Department, + permitio_user_set.teacher + ] +} +resource "permitio_condition_set_rule" "student_Courses_Where_Student_Can_Enroll_course_enroll" { + user_set = permitio_user_set.student.key + permission = "course:enroll" + resource_set = permitio_resource_set.Courses_Where_Student_Can_Enroll.key + depends_on = [ + permitio_resource_set.Courses_Where_Student_Can_Enroll, + permitio_user_set.student + ] +} + +# Resource Sets +resource "permitio_resource_set" "Courses_Where_Student_Can_Enroll" { + name = "Courses Where Student Can Enroll" + key = "Courses_Where_Student_Can_Enroll" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Where_Student_is_Enrolled_and_Same_Department" { + name = "Courses Where Student is Enrolled and Same Department" + key = "Courses_Where_Student_is_Enrolled_and_Same_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + }, + { + "resource.studentIds": { + "array_contains": { + "ref": "user.id" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} +resource "permitio_resource_set" "Courses_Matching_Teacher_Department" { + name = "Courses Matching Teacher Department" + key = "Courses_Matching_Teacher_Department" + resource = permitio_resource.course.key + conditions = jsonencode({ + "allOf": [ + { + "allOf": [ + { + "resource.department": { + "equals": { + "ref": "user.department" + } + } + } + ] + } + ] +}) + depends_on = [ + permitio_resource.course + ] +} + +# User Sets +resource "permitio_user_set" "admin" { + key = "admin" + name = "admin" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "admin" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} +resource "permitio_user_set" "student" { + key = "student" + name = "student" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "student" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} +resource "permitio_user_set" "teacher" { + key = "teacher" + name = "teacher" + conditions = jsonencode({ + allOf = [ + { + allOf = [ + { + "user.role" = { + equals = "teacher" + } + } + ] + } + ] +}) + depends_on = [ + permitio_user_attribute.user_role + ] +} \ No newline at end of file From 20287e8c580f21ed1909e877361deb0759b66d33 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Fri, 5 Dec 2025 15:13:04 +0100 Subject: [PATCH 31/34] Update .gitignore and remove unused README --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 4d87b2ec..99dbf5f2 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,6 @@ web_modules/ terraform_server/.env - # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache @@ -187,4 +186,4 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* +.pnp.* \ No newline at end of file From adbc42b803abdb10fa1b2d79845d99351b1f20da Mon Sep 17 00:00:00 2001 From: Tabintel Date: Fri, 5 Dec 2025 15:26:37 +0100 Subject: [PATCH 32/34] Remove duplicate lmsapp.tf file --- source/templates/lms-app/lmsapp.tf | 255 ----------------------------- 1 file changed, 255 deletions(-) delete mode 100644 source/templates/lms-app/lmsapp.tf diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf deleted file mode 100644 index a4c0ef6f..00000000 --- a/source/templates/lms-app/lmsapp.tf +++ /dev/null @@ -1,255 +0,0 @@ -terraform { - required_providers { - permitio = { - source = "permitio/permit-io" - version = "~> 0.0.14" - } - } -} - -provider "permitio" { - api_url = {{API_URL}} - api_key = {{API_KEY}} -} - -# Resources -resource "permitio_resource" "course" { - name = "course" - description = "" - key = "course" - - actions = { - "enroll" = { - name = "enroll" - }, - "read" = { - name = "read" - }, - "create" = { - name = "create" - }, - "delete" = { - name = "delete" - } - } - attributes = { - "department" = { - name = "Department" - type = "string" - }, - "studentIds" = { - name = "Student Ids" - type = "array" - }, - "teacherId" = { - name = "Teacher Id" - type = "string" - } - } -} - -# User Attributes -resource "permitio_user_attribute" "user_department" { - key = "department" - type = "string" - description = "" -} -resource "permitio_user_attribute" "user_id" { - key = "id" - type = "string" - description = "" -} -resource "permitio_user_attribute" "user_role" { - key = "role" - type = "string" - description = "user role" -} - -# Roles - -# Condition Set Rules -resource "permitio_condition_set_rule" "student_Courses_Where_Student_is_Enrolled_and_Same_Department_course_read" { - user_set = permitio_user_set.student.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department.key - depends_on = [ - permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department, - permitio_user_set.student - ] -} -resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_read" { - user_set = permitio_user_set.teacher.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.teacher - ] -} -resource "permitio_condition_set_rule" "student_Courses_Matching_Teacher_Department_course_read" { - user_set = permitio_user_set.student.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.student - ] -} -resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_create" { - user_set = permitio_user_set.teacher.key - permission = "course:create" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.teacher - ] -} -resource "permitio_condition_set_rule" "student_Courses_Where_Student_Can_Enroll_course_enroll" { - user_set = permitio_user_set.student.key - permission = "course:enroll" - resource_set = permitio_resource_set.Courses_Where_Student_Can_Enroll.key - depends_on = [ - permitio_resource_set.Courses_Where_Student_Can_Enroll, - permitio_user_set.student - ] -} - -# Resource Sets -resource "permitio_resource_set" "Courses_Where_Student_Can_Enroll" { - name = "Courses Where Student Can Enroll" - key = "Courses_Where_Student_Can_Enroll" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} -resource "permitio_resource_set" "Courses_Where_Student_is_Enrolled_and_Same_Department" { - name = "Courses Where Student is Enrolled and Same Department" - key = "Courses_Where_Student_is_Enrolled_and_Same_Department" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - }, - { - "resource.studentIds": { - "array_contains": { - "ref": "user.id" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} -resource "permitio_resource_set" "Courses_Matching_Teacher_Department" { - name = "Courses Matching Teacher Department" - key = "Courses_Matching_Teacher_Department" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} - -# User Sets -resource "permitio_user_set" "admin" { - key = "admin" - name = "admin" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "admin" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} -resource "permitio_user_set" "student" { - key = "student" - name = "student" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "student" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} -resource "permitio_user_set" "teacher" { - key = "teacher" - name = "teacher" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "teacher" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} \ No newline at end of file From 9ced17a9d1a3562c7f1f475ef2de8915e5aef906 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Sun, 7 Dec 2025 19:43:34 +0100 Subject: [PATCH 33/34] updates --- .gitignore | 1 + README.md | 30 +----------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 99dbf5f2..eff94491 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage !templates/*.tf !source/templates/*.tf + # .tfstate files *.tfstate *.tfstate.* diff --git a/README.md b/README.md index d4fde5fb..3b261122 100644 --- a/README.md +++ b/README.md @@ -1065,34 +1065,6 @@ Applies a policy template to your current environment, which is useful for quick ```bash $ permit env template apply --template mesa-verde-banking-dem ``` ---- - -#### Learning management system template example -This Terraform configuration defines a role- and attribute-based access control (ABAC + RBAC) model using the Permit.io provider. It provisions resources, user attributes, user sets, and conditional access rules for a course management system. - -**Key components:** - -- Provider setup: Connects to Permit.io via api_url and api_key. - -- Resources: Defines a course resource with actions (enroll, read, create, delete) and attributes (department, studentIds, teacherId). - -- User attributes: Establishes user fields like department, id, and role. - -- User sets: Groups users into roles (admin, teacher, student) based on their attributes. - -- Resource sets: Creates logical collections of courses based on conditions such as department or enrollment. - -- Condition set rules: Links user sets and resource sets with permissions to control who can read, create, or enroll in courses. - -This configuration provides a structured example of how to model fine-grained permissions in an education-style domain using Terraform and Permit.io. - -**Usage** -1. Install the Permit CLI: `npm install -g @permitio/cli` -2. Apply the template: -```bash -$ permit env template apply --template lmsapp -``` -3. Replace `{{API_KEY}}` in `lmsapp.tf` with your Permit API key. ### API Commands @@ -1655,4 +1627,4 @@ As a contributor, here are the guidelines we would like you to follow: ## There's more! - Check out [OPAL](https://github.com/permitio/OPAL) - the best way to manage Open Policy Agent (OPA), Cedar, and OpenFGA in scale. -- Check out [Cedar-Agent](https://github.com/permitio/cedar-agent), the easiest way to deploy & run AWS Cedar. \ No newline at end of file +- Check out [Cedar-Agent](https://github.com/permitio/cedar-agent), the easiest way to deploy & run AWS Cedar. From 436af9d2732ab969803b8dd946b92e10193705c7 Mon Sep 17 00:00:00 2001 From: Tabintel Date: Sun, 7 Dec 2025 20:07:16 +0100 Subject: [PATCH 34/34] Update --- source/templates/lms-app/lmsapp.tf | 255 ----------------------------- 1 file changed, 255 deletions(-) delete mode 100644 source/templates/lms-app/lmsapp.tf diff --git a/source/templates/lms-app/lmsapp.tf b/source/templates/lms-app/lmsapp.tf deleted file mode 100644 index a4c0ef6f..00000000 --- a/source/templates/lms-app/lmsapp.tf +++ /dev/null @@ -1,255 +0,0 @@ -terraform { - required_providers { - permitio = { - source = "permitio/permit-io" - version = "~> 0.0.14" - } - } -} - -provider "permitio" { - api_url = {{API_URL}} - api_key = {{API_KEY}} -} - -# Resources -resource "permitio_resource" "course" { - name = "course" - description = "" - key = "course" - - actions = { - "enroll" = { - name = "enroll" - }, - "read" = { - name = "read" - }, - "create" = { - name = "create" - }, - "delete" = { - name = "delete" - } - } - attributes = { - "department" = { - name = "Department" - type = "string" - }, - "studentIds" = { - name = "Student Ids" - type = "array" - }, - "teacherId" = { - name = "Teacher Id" - type = "string" - } - } -} - -# User Attributes -resource "permitio_user_attribute" "user_department" { - key = "department" - type = "string" - description = "" -} -resource "permitio_user_attribute" "user_id" { - key = "id" - type = "string" - description = "" -} -resource "permitio_user_attribute" "user_role" { - key = "role" - type = "string" - description = "user role" -} - -# Roles - -# Condition Set Rules -resource "permitio_condition_set_rule" "student_Courses_Where_Student_is_Enrolled_and_Same_Department_course_read" { - user_set = permitio_user_set.student.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department.key - depends_on = [ - permitio_resource_set.Courses_Where_Student_is_Enrolled_and_Same_Department, - permitio_user_set.student - ] -} -resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_read" { - user_set = permitio_user_set.teacher.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.teacher - ] -} -resource "permitio_condition_set_rule" "student_Courses_Matching_Teacher_Department_course_read" { - user_set = permitio_user_set.student.key - permission = "course:read" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.student - ] -} -resource "permitio_condition_set_rule" "teacher_Courses_Matching_Teacher_Department_course_create" { - user_set = permitio_user_set.teacher.key - permission = "course:create" - resource_set = permitio_resource_set.Courses_Matching_Teacher_Department.key - depends_on = [ - permitio_resource_set.Courses_Matching_Teacher_Department, - permitio_user_set.teacher - ] -} -resource "permitio_condition_set_rule" "student_Courses_Where_Student_Can_Enroll_course_enroll" { - user_set = permitio_user_set.student.key - permission = "course:enroll" - resource_set = permitio_resource_set.Courses_Where_Student_Can_Enroll.key - depends_on = [ - permitio_resource_set.Courses_Where_Student_Can_Enroll, - permitio_user_set.student - ] -} - -# Resource Sets -resource "permitio_resource_set" "Courses_Where_Student_Can_Enroll" { - name = "Courses Where Student Can Enroll" - key = "Courses_Where_Student_Can_Enroll" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} -resource "permitio_resource_set" "Courses_Where_Student_is_Enrolled_and_Same_Department" { - name = "Courses Where Student is Enrolled and Same Department" - key = "Courses_Where_Student_is_Enrolled_and_Same_Department" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - }, - { - "resource.studentIds": { - "array_contains": { - "ref": "user.id" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} -resource "permitio_resource_set" "Courses_Matching_Teacher_Department" { - name = "Courses Matching Teacher Department" - key = "Courses_Matching_Teacher_Department" - resource = permitio_resource.course.key - conditions = jsonencode({ - "allOf": [ - { - "allOf": [ - { - "resource.department": { - "equals": { - "ref": "user.department" - } - } - } - ] - } - ] -}) - depends_on = [ - permitio_resource.course - ] -} - -# User Sets -resource "permitio_user_set" "admin" { - key = "admin" - name = "admin" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "admin" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} -resource "permitio_user_set" "student" { - key = "student" - name = "student" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "student" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} -resource "permitio_user_set" "teacher" { - key = "teacher" - name = "teacher" - conditions = jsonencode({ - allOf = [ - { - allOf = [ - { - "user.role" = { - equals = "teacher" - } - } - ] - } - ] -}) - depends_on = [ - permitio_user_attribute.user_role - ] -} \ No newline at end of file