diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..9484cbb5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @grafana/grafana-oncall-backend + +/grafana-plugin @grafana/grafana-oncall-frontend diff --git a/.github/issue_and_pr_commands.json b/.github/issue_and_pr_commands.json index a1f4ec21..7b11337b 100644 --- a/.github/issue_and_pr_commands.json +++ b/.github/issue_and_pr_commands.json @@ -1,10 +1,10 @@ -[ - { - "type": "label", - "name": "type/docs", - "action": "addToProject", - "addToProject": { - "url": "https://github.com/orgs/grafana/projects/69" - } +[ + { + "type": "label", + "name": "type/docs", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/69" } + } ] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d70b0e4f..0fab1e81 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,9 @@ -**What this PR does**: +# What this PR does -**Which issue(s) this PR fixes**: +## Which issue(s) this PR fixes + +## Checklist -**Checklist** - [ ] Tests updated - [ ] Documentation added -- [ ] `CHANGELOG.md` updated \ No newline at end of file +- [ ] `CHANGELOG.md` updated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8eda99f..908f4d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | pre-commit run --all-files - test: + test-frontend: runs-on: ubuntu-latest container: python:3.9 steps: @@ -56,8 +56,12 @@ jobs: docker run -v ${PWD}/docs/sources:/hugo/content/docs/oncall/latest -e HUGO_REFLINKSERRORLEVEL=ERROR --rm grafana/docs-base:latest /bin/bash -c 'make hugo' unit-test-backend-mysql-rabbitmq: + name: "Backend Tests: MySQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DJANGO_SETTINGS_MODULE: settings.ci-test SLACK_CLIENT_OAUTH_ID: 1 @@ -72,7 +76,6 @@ jobs: env: MYSQL_DATABASE: oncall_local_dev MYSQL_ROOT_PASSWORD: local_dev_pwd - steps: - uses: actions/checkout@v2 - name: Unit Test Backend @@ -80,11 +83,15 @@ jobs: apt-get update && apt-get install -y netcat cd engine/ pip install -r requirements.txt - ./wait_for_test_mysql_start.sh && pytest --ds=settings.ci-test -x + ./wait_for_test_mysql_start.sh && ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x unit-test-backend-postgresql-rabbitmq: + name: "Backend Tests: PostgreSQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DATABASE_TYPE: postgresql DJANGO_SETTINGS_MODULE: settings.ci-test @@ -112,11 +119,15 @@ jobs: run: | cd engine/ pip install -r requirements.txt - pytest --ds=settings.ci-test -x + ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x unit-test-backend-sqlite-redis: + name: "Backend Tests: SQLite + Redis (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DATABASE_TYPE: sqlite3 BROKER_TYPE: redis @@ -131,7 +142,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - uses: actions/checkout@v2 - name: Unit Test Backend @@ -139,7 +149,7 @@ jobs: apt-get update && apt-get install -y netcat cd engine/ pip install -r requirements.txt - pytest --ds=settings.ci-test -x + ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x docker-build: runs-on: ubuntu-latest diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..62ff374f --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,12 @@ +{ + "default": true, + "MD013": { + "line_length": "120" + }, + "MD024": { + "siblings_only": true + }, + "MD033": { + "allowed_elements": ["img"] + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f61d3a85..9bc86f5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,10 +39,16 @@ repos: rev: "v2.7.1" hooks: - id: prettier - types_or: [css, javascript, jsx, ts, tsx, json] + name: prettier + types_or: [css, javascript, jsx, ts, tsx] files: ^grafana-plugin/src additional_dependencies: - prettier@^2.7.1 + - id: prettier + name: prettier - json + types_or: [json] + additional_dependencies: + - prettier@^2.7.1 - repo: https://github.com/thibaudcolas/pre-commit-stylelint rev: v13.13.1 @@ -56,3 +62,13 @@ repos: - stylelint-prettier@^2.0.0 - stylelint-config-standard@^22.0.0 - stylelint-config-prettier@^9.0.3 + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.32.2 + hooks: + - id: markdownlint + name: markdownlint + entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md + - id: markdownlint + name: markdownlint - docs + entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist -c ./docs/.markdownlint.json ./docs/**/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2d3f90..d7c8ead8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v1.2.0 (TBD) + +### Added + +- RBAC permission support +- Add `time_zone` serializer validation for OnCall shifts and calendar/web schedules. In addition, add database migration + to update values that may be invalid +- Add a `permalinks.web` field, which is a permalink to the alert group web app page, to the alert group internal/public + API responses +- Added the ability to customize job-migrate `ttlSecondsAfterFinished` field in the helm chart + +### Fixed + +- Got 500 error when saving Outgoing Webhook ([#890](https://github.com/grafana/oncall/issues/890)) +- v1.0.13 helm chart - update the OnCall backend pods image pull policy to "Always" (and explicitly set tag to `latest`). + This should resolve some recent issues experienced where the frontend/backend versions are not aligned. + +### Changed + +- When editing templates for alert group presentation or outgoing webhooks, errors and warnings are now displayed in + the UI as notification popups or displayed in the preview. +- Errors and warnings that occur when rendering templates during notification or webhooks will now render + and display the error/warning as the result. + ## v1.1.5 (2022-11-24) +### Added + +- Added a QR code in the "Mobile App Verification" tab on the user settings modal to connect the mobile + application to your OnCall instance + ### Fixed - UI bug fixes for Grafana 9.3 ([#860](https://github.com/grafana/oncall/pull/860)) - Bug fix for saving source link template ([#898](https://github.com/grafana/oncall/pull/898)) - ## v1.1.4 (2022-11-23) ### Fixed @@ -26,13 +54,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- For OSS installations of OnCall, initial configuration is now simplified. When running for local development, you no longer need to configure the plugin via the UI. This is achieved through passing one environment variable to both the backend & frontend containers, both of which have been preconfigured for you in `docker-compose-developer.yml`. - - The Grafana API URL **must be** passed as an environment variable, `GRAFANA_API_URL`, to the OnCall backend (and can be configured by updating this env var in your `./dev/.env.dev` file) - - The OnCall API URL can optionally be passed as an environment variable, `ONCALL_API_URL`, to the OnCall UI. If the environment variable is found, the plugin will "auto-configure", otherwise you will be shown a simple configuration form to provide this info. -- For Helm installations, if you are running Grafana externally (eg. `grafana.enabled` is set to `false` in your `values.yaml`), you will now be required to specify `externalGrafana.url` in `values.yaml`. -- `make start` will now idempotently check to see if a "127.0.0.1 grafana" record exists in `/etc/hosts` (using a tool called [`hostess`](https://github.com/cbednarski/hostess)). This is to support using `http://grafana:3000` as the `Organization.grafana_url` in two scenarios: +- For OSS installations of OnCall, initial configuration is now simplified. When running for local development, you no + longer need to configure the plugin via the UI. This is achieved through passing one environment variable to both the + backend & frontend containers, both of which have been preconfigured for you in `docker-compose-developer.yml`. + - The Grafana API URL **must be** passed as an environment variable, `GRAFANA_API_URL`, to the OnCall backend + (and can be configured by updating this env var in your `./dev/.env.dev` file) + - The OnCall API URL can optionally be passed as an environment variable, `ONCALL_API_URL`, to the OnCall UI. + If the environment variable is found, the plugin will "auto-configure", otherwise you will be shown a simple + configuration form to provide this info. +- For Helm installations, if you are running Grafana externally (eg. `grafana.enabled` is set to `false` + in your `values.yaml`), you will now be required to specify `externalGrafana.url` in `values.yaml`. +- `make start` will now idempotently check to see if a "127.0.0.1 grafana" record exists in `/etc/hosts` + (using a tool called [`hostess`](https://github.com/cbednarski/hostess)). This is to support using `http://grafana:3000` + as the `Organization.grafana_url` in two scenarios: - `oncall_engine`/`oncall_celery` -> `grafana` Docker container communication - - public URL generation. There are some instances where `Organization.grafana_url` is referenced to generate public URLs to a Grafana plugin page. Without the `/etc/hosts` record, navigating to `http://grafana:3000/some_page` in your browser, you would obviously get an error from your browser. + - public URL generation. There are some instances where `Organization.grafana_url` is referenced to generate public + URLs to a Grafana plugin page. Without the `/etc/hosts` record, navigating to `http://grafana:3000/some_page` in + your browser, you would obviously get an error from your browser. ## v1.1.2 (2022-11-18) @@ -260,7 +298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 1.0.2 (2022-06-17) - Fix Grafana Alerting integration to handle API changes in Grafana 9 -- Improve public api endpoint for outgoing webhooks (/actions) by adding ability to create, update and delete outgoing webhook instance +- Improve public api endpoint for outgoing webhooks (/actions) by adding ability to create, update and delete + outgoing webhook instance ## 1.0.0 (2022-06-14) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3d4caa4f..b3672f83 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,7 +2,10 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make +participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, +disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, +religion, or sexual identity and orientation. ## Our Standards @@ -24,19 +27,29 @@ Examples of unacceptable behavior by participants include: ## Our Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take +appropriate and fair corrective action in response to any instances of unacceptable behavior. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the +project or its community. Examples of representing a project or community include using an official project e-mail address, +posting via an official social media account, or acting as an appointed representative at an online or offline event. +Representation of a project may be further defined and clarified by project maintainers. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at conduct@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at +conduct@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems +appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter +of an incident. Further details of specific enforcement policies may be posted separately. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent +repercussions as determined by other members of the project's leadership. ## Attribution diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 9a63fb65..78186fd3 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,38 +1,46 @@ ---- -title: Governance ---- - # Governance -This document describes the rules and governance of the project. It is meant to be followed by all the developers of the project and the OnCall community. Common terminology used in this governance document are listed below: +This document describes the rules and governance of the project. It is meant to be followed by all the developers +of the project and the OnCall community. Common terminology used in this governance document are listed below: - **Team members**: Any members of the private [team mailing list][team]. - - **Maintainers**: Maintainers lead an individual project or parts thereof ([`MAINTAINERS.md`][maintainers]). - - **Projects**: A single repository in the Grafana GitHub organization and listed below is referred to as a project: - oncall -- **The OnCall project**: The sum of all activities performed under this governance, concerning one or more repositories or the community. +- **The OnCall project**: The sum of all activities performed under this governance, concerning one or more + repositories or the community. ## Values -The OnCall developers and community are expected to follow the values defined in the [Code of Conduct][coc]. Furthermore, the OnCall community strives for kindness, giving feedback effectively, and building a welcoming environment. The OnCall developers generally decide by consensus and only resort to conflict resolution by a majority vote if consensus cannot be reached. +The OnCall developers and community are expected to follow the values defined in the [Code of Conduct][coc]. Furthermore, +the OnCall community strives for kindness, giving feedback effectively, and building a welcoming environment. The OnCall +developers generally decide by consensus and only resort to conflict resolution by a majority vote if +consensus cannot be reached. ## Projects -Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the [announcements][announce] category at the GitHub Discussions. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. +Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release +process, access and documentation should be such that more than one person can perform a release. Releases should be +announced on the [announcements][announce] category at the GitHub Discussions. Any new projects should be first proposed +on the [team mailing list][team] following the voting procedures listed below. ## Decision making ### Team members -Team member status may be given to those who have made ongoing contributions to the OnCall project for at least 3 months. This is usually in the form of code improvements and/or notable work on documentation, but organizing events or user support could also be taken into account. +Team member status may be given to those who have made ongoing contributions to the OnCall project for at least 3 months. +This is usually in the form of code improvements and/or notable work on documentation, but organizing events or +user support could also be taken into account. -New members may be proposed by any existing member by email to the [team mailing list][team]. It is highly desirable to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a formal [supermajority vote](#supermajority-vote). +New members may be proposed by any existing member by email to the [team mailing list][team]. It is highly desirable +to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a +formal [supermajority vote](#supermajority-vote). -If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm or deny their acceptance of team membership. This email will also be CC'd to the [team mailing list][team] for record-keeping purposes. +If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm +or deny their acceptance of team membership. This email will also be CC'd to the [team mailing list][team] for +record-keeping purposes. If they choose to accept, the [onboarding](#onboarding) procedure is followed. @@ -57,6 +65,7 @@ The current team members are: - Yulia Shanyrova — [@Ukochka](https://github.com/Ukochka) ([Grafana Labs](https://grafana.com/)) - Maxim Mordasov — [@maskin25](https://github.com/maskin25) ([Grafana Labs](https://grafana.com/)) - Julia Artyukhina — [@Ferril](https://github.com/Ferril) ([Grafana Labs](https://grafana.com/)) +- Joey Orlando - [@joeyorlando](https://github.com/joeyorlando) ([Grafana Labs](https://grafana.com/)) Previous team members: @@ -64,21 +73,31 @@ Previous team members: ### Maintainers -Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable maintainers that, for whatever reason, are not yet team members. +Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the +contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable +maintainers that, for whatever reason, are not yet team members. -Changes in maintainership have to be announced on the [announcemount][announce] category at the GitHub Discussions. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. +Changes in maintainership have to be announced on the [announcemount][announce] category at the GitHub Discussions. +They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] +file of the respective repository. Maintainers are granted commit rights to all projects covered by this governance. -A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team member to take over the project. +A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity +for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team +member to take over the project. -A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This includes coordinating who handles which issues and pull requests. +A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This +includes coordinating who handles which issues and pull requests. ### Technical decisions -Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be discussed and made on the the [GitHub Discussions][discussions]. +Technical decisions that only affect a single project are made informally by the maintainer of this project, and +[rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be +discussed and made on the the [GitHub Discussions][discussions]. -Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved by [majority vote](#majority-vote). +Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved +by [majority vote](#majority-vote). ### Governance changes @@ -86,7 +105,9 @@ Changes to this document are made by Grafana Labs. ### Other matters -Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [GitHub Discussions][discussions]. +Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or +personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise +on the [GitHub Discussions][discussions]. ## Voting @@ -94,45 +115,67 @@ The OnCall project usually runs by informal consensus, however sometimes a forma Depending on the subject matter, as laid out [above](#decision-making), different methods of voting are used. -For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. A vote may be called and closed early if enough votes have come in one way so that further votes cannot change the final decision. +For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. +A vote may be called and closed early if enough votes have come in one way so that further votes cannot +change the final decision. -In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced removal of a team member, in which said member is not eligible to vote. +In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced +removal of a team member, in which said member is not eligible to vote. -Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [GitHub Discussions][discussions]. +Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in +private on the [team mailing list][team]. All other discussion and votes are held in public +on the [GitHub Discussions][discussions]. -For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to [team members](#team-members). +For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to +[team members](#team-members). ### Consensus -The default decision making mechanism for the OnCall project is [rough][rough] consensus. This means that any decision on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been considered but not necessarily accommodated. +The default decision making mechanism for the OnCall project is [rough][rough] consensus. This means that any decision +on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been +considered but not necessarily accommodated. -Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [GitHub Discussions][discussions] at any time and by anyone. +Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may +be stated at will. Decisions may, but do not need to be called out and put up for decision on the +[GitHub Discussions][discussions] at any time and by anyone. Consensus decisions can never override or go against the spirit of an earlier explicit vote. -If any [team member](#team-members) raises objections, the team members work together towards a solution that all involved can accept. This solution is again subject to rough consensus. +If any [team member](#team-members) raises objections, the team members work together towards a solution that all +involved can accept. This solution is again subject to rough consensus. -In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) may call a formal [majority vote](#majority-vote). +In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) +may call a formal [majority vote](#majority-vote). ### Majority vote -Majority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. +Majority votes must be called explicitly in a separate thread on the appropriate mailing list. +The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. +It should reference any discussion leading up to this point. Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. A vote on a single proposal is considered successful if more vote in favor than against. -If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all +alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in +favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. +Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. ### Supermajority vote -Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. +Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. +The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. +It should reference any discussion leading up to this point. Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. -If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to +all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received +the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach +this quorum, another vote on a reduced number of options may be called separately. ## On- / Offboarding @@ -141,7 +184,8 @@ If there are multiple alternatives, members may vote for one or more alternative The new member is - added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. -- announced on the [GitHub Discussions][discussions] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. +- announced on the [GitHub Discussions][discussions] by an existing team member. Ideally, the new member + replies in this thread, acknowledging team membership. - added to the projects with commit rights. - added to the [team mailing list][team]. @@ -149,15 +193,16 @@ The new member is The ex-member is -- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. In case of forced removal, no approval is needed. -- removed from the projects. Optionally, they can retain maintainership of one or more repositories if the [team](#team-members) agrees. +- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. + In case of forced removal, no approval is needed. +- removed from the projects. Optionally, they can retain maintainership of one or more repositories + if the [team](#team-members) agrees. - removed from the team mailing list and demoted to a normal member of the other mailing lists. - not allowed to call themselves an active team member any more, nor allowed to imply this to be the case. - added to a list of previous members if they so choose. If needed, we reserve the right to publicly announce removal. - [announce]: https://github.com/grafana/oncall/discussions/categories/announcements [coc]: https://github.com/grafana/oncall/blob/dev/CODE_OF_CONDUCT.md [maintainers]: https://github.com/grafana/oncall/blob/dev/MAINTAINERS.md diff --git a/LICENSING.md b/LICENSING.md index 34951583..6a4ef47e 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -8,12 +8,12 @@ The default license for this project is [AGPL-3.0-only](LICENSE). The following directories and their subdirectories are licensed under Apache-2.0: -``` +```text n/a ``` The following directories and their subdirectories are licensed under their original upstream licenses: -``` +```text n/a ``` diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 8447090b..adc819b4 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,3 +1,5 @@ +# Maintainers + The following are the main/default maintainers: - Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) @@ -6,6 +8,7 @@ The following are the main/default maintainers: Some parts of the codebase have other maintainers, the package paths also include all sub-packages: Some parts of the codebase have other maintainers: + - `docs`: - Eve Meelan - [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) - Alyssa Wada - [@alyssawada](https://github.com/alyssawada) ([Grafana Labs](https://grafana.com/)) diff --git a/Makefile b/Makefile index 79f3fbaa..52efd02e 100644 --- a/Makefile +++ b/Makefile @@ -120,6 +120,9 @@ shell: dbshell: $(call run_engine_docker_command,python manage.py dbshell) +engine-manage: + $(call run_engine_docker_command,python manage.py $(CMD)) + exec-engine: docker exec -it oncall_engine bash diff --git a/README.md b/README.md index b0ce2702..035a2890 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Grafana OnCall + [![Latest Release](https://img.shields.io/github/v/release/grafana/oncall?display_name=tag&sort=semver)](https://github.com/grafana/oncall/releases) @@ -26,31 +28,34 @@ We prepared multiple environments: 1. Download [`docker-compose.yml`](docker-compose.yml): -```bash -curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.yml -o docker-compose.yml -``` + ```bash + curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.yml -o docker-compose.yml + ``` 2. Set variables: -```bash -echo "DOMAIN=http://localhost:8080 -COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana -SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long" > .env -``` + ```bash + echo "DOMAIN=http://localhost:8080 + COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana + SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long" > .env + ``` 3. Launch services: -```bash -docker-compose up -d -``` + ```bash + docker-compose pull && docker-compose up -d + ``` -4. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_: +4. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials + as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ + with OnCall _backend_: -``` -OnCall backend URL: http://engine:8080 -``` + ```text + OnCall backend URL: http://engine:8080 + ``` -5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud. +5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up + Slack, Telegram, Twilio or SMS/calls through Grafana Cloud. ## Update version @@ -65,13 +70,14 @@ docker-compose up -d ``` After updating the engine, you'll also need to click the "Update" button on the [plugin version page](http://localhost:3000/plugins/grafana-oncall-app?page=version-history). -See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin-management/#update-a-plugin) for more info on updating Grafana plugins. +See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin-management/#update-a-plugin) for more +info on updating Grafana plugins. ## Join community - - - +[](https://github.com/grafana/oncall/discussions/categories/community-calls) +[](https://github.com/grafana/oncall/discussions) +[](https://slack.grafana.com/) ## Stargazers over time diff --git a/SECURITY.md b/SECURITY.md index b8697c16..349dbe51 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,9 @@ # Reporting security issues -If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. +If you think you have found a security vulnerability, please send a report to +[security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and +commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). +We can accept only vulnerability reports at this address. Please encrypt your message to us; please use our PGP key. The key fingerprint is: @@ -8,13 +11,18 @@ F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA&fingerprint=on&op=index). -Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. +Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply +to your report, the security team will keep you informed of the progress towards a fix and full announcement, +and may ask for additional information or guidance. -**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. +**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you +received a response from the Grafana Labs security team that you can do so. ## Security announcements -We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/support/security-announcements), -where we will post a summary, remediation, and mitigation details for any patch containing security fixes. +We maintain a category on the community site called +[Security Announcements](https://community.grafana.com/c/support/security-announcements), where we will post a summary, +remediation, and mitigation details for any patch containing security fixes. -You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss). +You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the +community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss). diff --git a/dev/README.md b/dev/README.md index 9f858e3c..5fd5dbea 100644 --- a/dev/README.md +++ b/dev/README.md @@ -11,37 +11,49 @@ - [Troubleshooting](#troubleshooting) - [ld: library not found for -lssl](#ld-library-not-found-for--lssl) - [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly) - - [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value) + - [django.db.utils.OperationalError: (1366, "Incorrect string value")](#djangodbutilsoperationalerror-1366-incorrect-string-value) - [/bin/sh: line 0: cd: grafana-plugin: No such file or directory](#binsh-line-0-cd-grafana-plugin-no-such-file-or-directory) - [IDE Specific Instructions](#ide-specific-instructions) - - [PyCharm](#pycharm-professional-edition) + - [PyCharm](#pycharm) Related: [How to develop integrations](/engine/config_integrations/README.md) ## Running the project -By default everything runs inside Docker. These options can be modified via the [`COMPOSE_PROFILES`](#compose_profiles) environment variable. +By default everything runs inside Docker. These options can be modified via the [`COMPOSE_PROFILES`](#compose_profiles) +environment variable. -1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose v2. For instructions on how to enable this (if you haven't already done so), see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/). Ensure you have Docker Compose version 2.10 or above installed - update instructions are [here](https://docs.docker.com/compose/install/linux/). -2. Run `make init start`. By default this will run everything in Docker, using SQLite as the database and Redis as the message broker/cache. See [Running in Docker](#running-in-docker) below for more details on how to swap out/disable which components are run in Docker. +1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. + **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose + v2. For instructions on how to enable this (if you haven't already done so), + see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/). Ensure you have Docker Compose + version 2.10 or above installed - update instructions are [here](https://docs.docker.com/compose/install/linux/). +2. Run `make init start`. By default this will run everything in Docker, using SQLite as the database and Redis as the + message broker/cache. See [`COMPOSE_PROFILES`](#compose_profiles) below for more details on how to swap + out/disable which components are run in Docker. 3. Open Grafana in a browser [here](http://localhost:3000/plugins/grafana-oncall-app) (login: `oncall`, password: `oncall`). 4. You should now see the OnCall plugin configuration page. Fill out the configuration options as follows: -- OnCall backend URL: http://host.docker.internal:8080 (this is the URL that is running the OnCall API; it should be accessible from Grafana) -- Grafana URL: http://grafana:3000 (this is the URL OnCall will use to talk to the Grafana Instance) + - OnCall backend URL: (this is the URL that is running the OnCall API; it should be + accessible from Grafana) + - Grafana URL: (this is the URL OnCall will use to talk to the Grafana Instance) -5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud. +5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, + Telegram, Twilio or SMS/calls through Grafana Cloud. 6. (Optional) Install `pre-commit` hooks by running `make install-precommit-hook` -**Note**: on subsequent startups you can simply run `make start`, this is a bit faster because it skips the frontend build step. +**Note**: on subsequent startups you can simply run `make start`, this is a bit faster because it skips the frontend +build step. ### `COMPOSE_PROFILES` -This configuration option represents a comma-separated list of [`docker-compose` profiles](https://docs.docker.com/compose/profiles/). It allows you to swap-out, or disable, certain components in Docker. +This configuration option represents a comma-separated list of [`docker-compose` profiles](https://docs.docker.com/compose/profiles/). +It allows you to swap-out, or disable, certain components in Docker. This option can be configured in two ways: -1. Setting a `COMPOSE_PROFILES` environment variable in `dev/.env.dev`. This allows you to avoid having to set `COMPOSE_PROFILES` for each `make` command you execute afterwards. +1. Setting a `COMPOSE_PROFILES` environment variable in `dev/.env.dev`. This allows you to avoid having to set + `COMPOSE_PROFILES` for each `make` command you execute afterwards. 2. Passing in a `COMPOSE_PROFILES` argument when running `make` commands. For example: ```bash @@ -66,16 +78,29 @@ The default is `engine,oncall_ui,redis,grafana`. This runs: ### `GRAFANA_VERSION` -If you would like to change the version of Grafana being run, simply pass in a `GRAFANA_VERSION` environment variable to `make start` (or alternatively set it in your `.env.dev` file). The value of this environment variable should be a valid `grafana/grafana` published Docker [image tag](https://hub.docker.com/r/grafana/grafana/tags). +If you would like to change the version of Grafana being run, simply pass in a `GRAFANA_VERSION` environment variable +to `make start` (or alternatively set it in your `.env.dev` file). The value of this environment variable should be a +valid `grafana/grafana` published Docker [image tag](https://hub.docker.com/r/grafana/grafana/tags). ### Running backend services outside Docker -By default everything runs inside Docker. If you would like to run the backend services outside of Docker (for integrating w/ PyCharm for example), follow these instructions: +By default everything runs inside Docker. If you would like to run the backend services outside of Docker +(for integrating w/ PyCharm for example), follow these instructions: -1. Create a Python 3.9 virtual environment using a method of your choosing (ex. [venv](https://docs.python.org/3.9/library/venv.html) or [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv)). Make sure the virtualenv is "activated". -2. `postgres` is a dependency on some of our Python dependencies (notably `psycopg2` ([docs](https://www.psycopg.org/docs/install.html#prerequisites))). Please visit [here](https://www.postgresql.org/download/) for installation instructions. +1. Create a Python 3.9 virtual environment using a method of your choosing (ex. + [venv](https://docs.python.org/3.9/library/venv.html) or [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv)). + Make sure the virtualenv is "activated". +2. `postgres` is a dependency on some of our Python dependencies (notably `psycopg2` + ([docs](https://www.psycopg.org/docs/install.html#prerequisites))). Please visit + [here](https://www.postgresql.org/download/) for installation instructions. 3. `make backend-bootstrap` - installs all backend dependencies -4. Modify your `.env.dev` by copying the contents of one of `.env.mysql.dev`, `.env.postgres.dev`, or `.env.sqlite.dev` into `.env.dev` (you should exclude the `GF_` prefixed environment variables). In most cases where you are running stateful services via `docker-compose` and backend services outside of docker you will simply need to change the database host to `localhost` (or in the case of `sqlite` update the file-path to your `sqlite` database file). +4. Modify your `.env.dev` by copying the contents of one of `.env.mysql.dev`, `.env.postgres.dev`, + or `.env.sqlite.dev` into `.env.dev` (you should exclude the `GF_` prefixed environment variables). + + > In most cases where you are running stateful services via `docker-compose`, and backend services outside of + > docker, you will simply need to change the database host to `localhost` (or in the case of `sqlite` update + > the file-path to your `sqlite` database file). You will need to change the broker host to `localhost` as well. + 5. `make backend-migrate` - runs necessary database migrations 6. Open two separate shells and then run the following: @@ -92,6 +117,9 @@ make start # start all of the docker containers make stop # stop all of the docker containers make restart # restart all docker containers make build # rebuild images (e.g. when changing requirements.txt) +# run Django's `manage.py` script, inside of a docker container, passing `$CMD` as arguments. +# e.g. `make engine-manage CMD="makemigrations"` - https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations +make engine-manage CMD="..." # this will remove all of the images, containers, volumes, and networks # associated with your local OnCall developer setup @@ -105,7 +133,7 @@ make exec-engine # exec into engine container's bash make test # run backend tests # run Django's `manage.py` script, passing `$CMD` as arguments. -# e.g. `make backend-manage-command makemigrations` - https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations +# e.g. `make backend-manage-command CMD="makemigrations"` - https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations make backend-manage-command CMD="..." # run both frontend and backend linters @@ -115,11 +143,13 @@ make lint ## Setting environment variables -If you need to override any additional environment variables, you should set these in a root `.env.dev` file. This file is automatically picked up by the OnCall engine Docker containers. This file is ignored from source control and also overrides any defaults that are set in other `.env*` files +If you need to override any additional environment variables, you should set these in a root `.env.dev` file. +This file is automatically picked up by the OnCall engine Docker containers. This file is ignored from source control +and also overrides any defaults that are set in other `.env*` files ## Slack application setup -For Slack app configuration check our docs: https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup +For Slack app configuration check our docs: ## Update drone build @@ -144,7 +174,7 @@ drone sign --save grafana/oncall .drone.yml **Problem:** -``` +```bash make backend-bootstrap ... ld: library not found for -lssl @@ -155,7 +185,7 @@ make backend-bootstrap **Solution:** -``` +```bash export LDFLAGS=-L/usr/local/opt/openssl/lib make backend-bootstrap ``` @@ -166,30 +196,38 @@ Happens on Apple Silicon **Problem:** -``` - build/temp.macosx-12-arm64-3.9/_openssl.c:575:10: fatal error: 'openssl/opensslv.h' file not found - #include - ^~~~~~~~~~~~~~~~~~~~ - 1 error generated. - error: command '/usr/bin/clang' failed with exit code 1 - ---------------------------------------- - ERROR: Failed building wheel for cryptography +```bash +build/temp.macosx-12-arm64-3.9/_openssl.c:575:10: fatal error: 'openssl/opensslv.h' file not found +#include + ^~~~~~~~~~~~~~~~~~~~ +1 error generated. +error: command '/usr/bin/clang' failed with exit code 1 +---------------------------------------- +ERROR: Failed building wheel for cryptography ``` **Solution:** -``` + + +```bash LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1.1)/include" pip install `cat engine/requirements.txt | grep cryptography` ``` -### django.db.utils.OperationalError: (1366, "Incorrect string value ...") + + +### django.db.utils.OperationalError: (1366, "Incorrect string value") **Problem:** -``` + + +```bash django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x98\\x8A\\xF0\\x9F...' for column 'cached_name' at row 1") ``` + + **Solution:** Recreate the database with the correct encoding. @@ -200,18 +238,19 @@ Recreate the database with the correct encoding. When running `make init`: -``` +```bash /bin/sh: line 0: cd: grafana-plugin: No such file or directory make: *** [init] Error 1 ``` -This arises when the environment variable `[CDPATH](https://www.theunixschool.com/2012/04/what-is-cdpath.html)` is set _and_ when the current path (`.`) is not explicitly part of `CDPATH`. +This arises when the environment variable `[CDPATH](https://www.theunixschool.com/2012/04/what-is-cdpath.html)` is +set _and_ when the current path (`.`) is not explicitly part of `CDPATH`. **Solution:** Either make `.` part of `CDPATH` in your .rc file setup, or temporarily override the variable when running `make` commands: -``` +```bash $ CDPATH="." make init # Setting CDPATH to empty seems to also work - only tested on zsh, YMMV $ CDPATH="" make init @@ -221,15 +260,19 @@ $ CDPATH="" make init When running `make init start`: -``` + + +```bash Error response from daemon: open /var/lib/docker/overlay2/ac57b871108ee1b98ff4455e36d2175eae90cbc7d4c9a54608c0b45cfb7c6da5/committed: is a directory make: *** [start] Error 1 ``` -**Solution:** -clear everything in docker by resetting or: + -``` +**Solution:** +clear everything in docker by resetting or: + +```bash make cleanup ``` @@ -240,7 +283,8 @@ make cleanup 1. Follow the instructions listed in ["Running backend services outside Docker"](#running-backend-services-outside-docker). 2. Open the project in PyCharm 3. Settings → Project OnCall - - In Python Interpreter click the gear and create a new Virtualenv from existing environment selecting the venv created in Step 1. + - In Python Interpreter click the gear and create a new Virtualenv from existing environment selecting the + venv created in Step 1. - In Project Structure make sure the project root is the content root and add /engine to Sources 4. Under Settings → Languages & Frameworks → Django - Enable Django support diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index e8208bbb..55884d05 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -13,6 +13,9 @@ x-oncall-volumes: &oncall-volumes # https://stackoverflow.com/a/60456034 - ${ENTERPRISE_ENGINE:-/dev/null}:/etc/app/extensions/engine_enterprise - ${SQLITE_DB_FILE:-/dev/null}:/var/lib/oncall/oncall.db + # this is mounted for testing purposes. Some of the authorization tests + # reference this file + - ./grafana-plugin/src/plugin.json:/etc/grafana-plugin/src/plugin.json x-env-files: &oncall-env-files - ./dev/.env.dev @@ -178,7 +181,7 @@ services: container_name: mysql labels: *oncall-labels image: mysql:5.7 - platform: linux/x86_64 + platform: linux/amd64 command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: always environment: @@ -205,7 +208,7 @@ services: container_name: mysql_to_create_grafana_db labels: *oncall-labels image: mysql:5.7 - platform: linux/x86_64 + platform: linux/amd64 command: bash -c "mysql -h mysql -uroot -pempty -e 'CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'" depends_on: mysql: diff --git a/docs/.markdownlint.json b/docs/.markdownlint.json new file mode 100644 index 00000000..15faa1ca --- /dev/null +++ b/docs/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "extends": "../.markdownlint.json", + "MD025": false, + "MD036": false +} diff --git a/docs/README.md b/docs/README.md index 2a78a922..98e94162 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,10 @@ # Grafana Cloud Documentation -Source for documentation at https://grafana.com/docs/oncall/ +Source for documentation at ## Preview the website -Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/oncall/latest/` which will refresh automatically when changes are made to content in the `sources` directory. +Run `make docs`. This launches a preview of the website with the current grafana docs at +`http://localhost:3002/docs/oncall/latest/` which will refresh automatically when changes are made to +content in the `sources` directory. Make sure Docker is running. diff --git a/docs/sources/_index.md b/docs/sources/_index.md index 7b70bfde..bb0c2a60 100644 --- a/docs/sources/_index.md +++ b/docs/sources/_index.md @@ -18,15 +18,22 @@ weight: 1000 +Grafana OnCall is an open source incident response management tool built to help teams improve their collaboration and +resolve incidents faster. Some of the core strengths of Grafana OnCall include: -Grafana OnCall is an open source incident response management tool built to help teams improve their collaboration and resolve incidents faster. Some of the core strengths of Grafana OnCall include: - -- **Support for a broad set of monitoring systems:** Grafana OnCall supports integrations with many monitoring systems, including Grafana, Prometheus, Alertmanager, Zabbix, and more. -- **Reduce alert noise:** Automatic alert grouping helps avoid alert storms and reduce noise during incidents. Auto-resolve settings can resolve without human intervention when the resolve conditions are met, enabling you to control alert noise and reduce alert fatigue. -- **Automatic escalation to on-call rotations:** Grafana OnCall’s flexible calendar integration allows you to define your on-call rotations while managing on-call schedules in your preferred calendar application with iCal format. Configurable alert escalation automatically escalates alerts to on-call team members, notifies slack channels, and more. -- **ChatOps focused:** Grafana OnCall integrates closely with your slack workspace to deliver alert notifications to individuals and groups, making daily alerts more visible and easier to manage. -- **Highly customizable**: With customizable alert grouping and routing, you can decide which alerts you want to be notified of and how, ensuring the right people are notified for the right issues. -- **Massive scalability:** Grafana OnCall is equipped with a full API and Terraform capabilities. Ready for GitOps and large organization configuration. - +- **Support for a broad set of monitoring systems:** Grafana OnCall supports integrations with many monitoring systems, + including Grafana, Prometheus, Alertmanager, Zabbix, and more. +- **Reduce alert noise:** Automatic alert grouping helps avoid alert storms and reduce noise during incidents. + Auto-resolve settings can resolve without human intervention when the resolve conditions are met, enabling you to + control alert noise and reduce alert fatigue. +- **Automatic escalation to on-call rotations:** Grafana OnCall’s flexible calendar integration allows you to define + your on-call rotations while managing on-call schedules in your preferred calendar application with iCal format. + Configurable alert escalation automatically escalates alerts to on-call team members, notifies slack channels, and more. +- **ChatOps focused:** Grafana OnCall integrates closely with your slack workspace to deliver alert notifications to + individuals and groups, making daily alerts more visible and easier to manage. +- **Highly customizable**: With customizable alert grouping and routing, you can decide which alerts you want to be + notified of and how, ensuring the right people are notified for the right issues. +- **Massive scalability:** Grafana OnCall is equipped with a full API and Terraform capabilities. Ready for GitOps + and large organization configuration. {{< section >}} diff --git a/docs/sources/alert-behavior/_index.md b/docs/sources/alert-behavior/_index.md index d1204cbf..2a2ffa82 100644 --- a/docs/sources/alert-behavior/_index.md +++ b/docs/sources/alert-behavior/_index.md @@ -8,16 +8,17 @@ weight: 900 # Configure alert behavior for Grafana OnCall -The available alert configurations in Grafana OnCall allow you to define how certain alerts are handled and ensure that alerts are routed, escalated, and grouped to fit your specific alerting needs. Grafana OnCall can receive alerts from any monitoring system that sends alerts via webhook. +The available alert configurations in Grafana OnCall allow you to define how certain alerts are handled and ensure that +alerts are routed, escalated, and grouped to fit your specific alerting needs. Grafana OnCall can receive alerts from +any monitoring system that sends alerts via webhook. - -## About alert behavior +## About alert behavior Once Grafana OnCall receives an alert, the following occurs, based on the alert content: -- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, in a readable format. +- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, + in a readable format. - Alerts are grouped based on your alert grouping configurations, combining similar or related alerts to reduce alert noise. - Alerts automatically resolve if an alert from the monitoring system matches the resolve condition for that alert. - -{{< section >}} \ No newline at end of file +{{< section >}} diff --git a/docs/sources/alert-behavior/alert-templates/index.md b/docs/sources/alert-behavior/alert-templates/index.md index 5bc4321c..0269dfc8 100644 --- a/docs/sources/alert-behavior/alert-templates/index.md +++ b/docs/sources/alert-behavior/alert-templates/index.md @@ -15,7 +15,10 @@ weight: 300 # Configure alert templates -Grafana OnCall can integrate with any monitoring systems that can send alerts using webhooks with JSON payloads. By default, webhooks deliver raw JSON payloads. When Grafana OnCall receives an alert and parses its payload, a default pre configured alert template is applied to modify the alert payload to be more human readable. These alert templates are customizable for any integration. +Grafana OnCall can integrate with any monitoring systems that can send alerts using webhooks with JSON payloads. By +default, webhooks deliver raw JSON payloads. When Grafana OnCall receives an alert and parses its payload, a default +pre configured alert template is applied to modify the alert payload to be more human readable. These alert templates +are customizable for any integration. See Format alerts with alert templates in this document to learn more about how to customize alert templates. @@ -23,13 +26,15 @@ See Format alerts with alert templates in this document to learn more about how Once Grafana OnCall receives an alert, the following occurs, based on the alert content: -- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, in a readable format. +- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, + in a readable format. - Alerts are grouped based on your alert grouping configurations, combining similar or related alerts to reduce alert noise. - Alerts automatically resolve if an alert from the monitoring system matches the resolve condition for that alert. ## Alert payload -Alerts received by Grafana OnCall contain metadata as keys and values in a JSON object. The following is an example of an alert from Grafana OnCall: +Alerts received by Grafana OnCall contain metadata as keys and values in a JSON object. The following is an example of +an alert from Grafana OnCall: ```json { @@ -68,16 +73,21 @@ The JSON payload is converted. For example: - `{{ payload.message }}` -> Message - `{{ payload.imageUrl }}` -> Image Url -The result is that each field of the alert in OnCall is now mapped to the JSON payload keys. This also true for the alert behavior: +The result is that each field of the alert in OnCall is now mapped to the JSON payload keys. This also true for the +alert behavior: - `{{ payload.ruleId }}` -> Grouping Id - `{{ 1 if payload.state == 'OK' else 0 }}` -> Resolve Signal -Grafana OnCall provides a pre configured default Jinja template for supported integrations. If your monitoring system is not in the Grafana OnCall integrations list, you can create a generic `webhook` integration, send an alert, and configure your templates. +Grafana OnCall provides a pre configured default Jinja template for supported integrations. If your monitoring system is +not in the Grafana OnCall integrations list, you can create a generic `webhook` integration, send an alert, and configure +your templates. ## Customize alerts with alert templates -Alert templates allow you to format any alert fields recognized by Grafana OnCall. You can customize default alert templates for all the different ways you receive your alerts such as web, slack, SMS, and email. For more advanced customization, use Jinja templates. +Alert templates allow you to format any alert fields recognized by Grafana OnCall. You can customize default alert +templates for all the different ways you receive your alerts such as web, slack, SMS, and email. For more advanced +customization, use Jinja templates. As a best practice, add _Playbooks_, _Useful links_, or _Checklists_ to the alert message. @@ -97,23 +107,28 @@ To customize alert templates in Grafana OnCall: 4. Edit the alert behavior as needed: - `Grouping Id` - This output groups other alerts into a single alert group. - - `Acknowledge Condition` - The output should be `ok`, `true`, or `1` to auto-acknowledge the alert group. For example, `{{ 1 if payload.state == 'OK' else 0 }}`. - - `Resolve Condition` - The output should be `ok`, `true` or `1` to auto-resolve the alert group. For example, `{{ 1 if payload.state == 'OK' else 0 }}`. + - `Acknowledge Condition` - The output should be `ok`, `true`, or `1` to auto-acknowledge the alert group. + For example, `{{ 1 if payload.state == 'OK' else 0 }}`. + - `Resolve Condition` - The output should be `ok`, `true` or `1` to auto-resolve the alert group. + For example, `{{ 1 if payload.state == 'OK' else 0 }}`. - `Source Link` - Used to customize the URL link to provide as the "source" of the alert. ## Advanced Jinja templates -Grafana OnCall uses [Jinja templating language](http://jinja.pocoo.org/docs/2.10/) to format alert groups for the Web, Slack, phone calls, SMS messages, and more because the JSON format is not easily readable by humans. As a result, you can decide what you want to see when an alert group is triggered as well as how it should be presented. +Grafana OnCall uses [Jinja templating language](http://jinja.pocoo.org/docs/2.10/) to format alert groups for the Web, +Slack, phone calls, SMS messages, and more because the JSON format is not easily readable by humans. As a result, you +can decide what you want to see when an alert group is triggered as well as how it should be presented. Jinja2 offers simple but multi-faceted functionality by using loops, conditions, functions, and more. > **NOTE:** Every alert from a monitoring system comes in the key/value format. - Grafana OnCall has rules about which of the keys match to: `__title`, `message`, `image`, `grouping`, and `auto-resolve__`. +Grafana OnCall has rules about which of the keys match to: `__title`, `message`, `image`, `grouping`, and `auto-resolve__`. ### Loops -Monitoring systems can send an array of values. In this example, you can use Jinja to iterate and format the alert using a Grafana example: +Monitoring systems can send an array of values. In this example, you can use Jinja to iterate and format the alert +using a Grafana example: ```.jinja2 *Values:* diff --git a/docs/sources/alert-behavior/outgoing-webhooks/index.md b/docs/sources/alert-behavior/outgoing-webhooks/index.md index a88ccbf9..7931f6ea 100644 --- a/docs/sources/alert-behavior/outgoing-webhooks/index.md +++ b/docs/sources/alert-behavior/outgoing-webhooks/index.md @@ -16,26 +16,25 @@ weight: 500 # Configure outgoing webhooks for Grafana OnCall -Outgoing webhooks allow you to send alert details to a specified URL from Grafana OnCall. Once an outgoing webhook is configured, you can use it as a notification method in escalation chains. +Outgoing webhooks allow you to send alert details to a specified URL from Grafana OnCall. Once an outgoing webhook is +configured, you can use it as a notification method in escalation chains. To automatically send alert data to a destination URL via outgoing webhook: 1. In Grafana OnCall, navigate to **Outgoing Webhooks** and click **+ Create**. This is also the place to edit and delete existing outgoing webhooks. - 2. Provide a name for your outgoing webhook and enter the destination URL. - 3. If the destination requires authentication, enter your credentials. You can enter a username and password (HTTP) or an authorization header formatted in JSON. - 4. Configure the webhook payload in the **Data** field. 5. Click **Create Webhook**. -The format you use to call the variables must match the structure of how the fields are nested in the alert payload. The **Data** field can use the following four variables to auto-populate the webhook payload with information about the first alert in the alert group: +The format you use to call the variables must match the structure of how the fields are nested in the alert payload. +The **Data** field can use the following four variables to auto-populate the webhook payload with information about +the first alert in the alert group: - `{{ alert_payload }}` - `{{ alert_group_id }}` -
`alert_payload` is always the first level of any variable you want to call. @@ -48,4 +47,5 @@ The following is an example of an entry in the **Data** field that might return } ``` -> **NOTE:** If you receive an error message and cannot create an outgoing webhook, verify that your JSON is formatted correctly. +> **NOTE:** If you receive an error message and cannot create an outgoing webhook, verify that your JSON is +> formatted correctly. diff --git a/docs/sources/calendar-schedules/_index.md b/docs/sources/calendar-schedules/_index.md index c4838c5d..991e409d 100644 --- a/docs/sources/calendar-schedules/_index.md +++ b/docs/sources/calendar-schedules/_index.md @@ -14,49 +14,55 @@ weight: 1100 # Configure and manage on-call schedules -Grafana OnCall allows you to use any calendar service that uses the iCal format to create customized on-call schedules for team members. Using Grafana OnCall, you can create a primary calendar that acts as a read-only schedule, and an override calendar that allows all team members to modify schedules as they change. +Grafana OnCall allows you to use any calendar service that uses the iCal format to create customized on-call schedules +for team members. Using Grafana OnCall, you can create a primary calendar that acts as a read-only schedule, and an +override calendar that allows all team members to modify schedules as they change. To learn more about creating on-call calendars, see the following topics: -# Configure and manage on-call schedules +## Configure and manage on-call schedules -You can use any calendar with an iCal address to schedule on-call times for users. During these times, notifications configured in escalation chains with the **Notify users from an on-call schedule** setting will be sent to the the person scheduled. You can also schedule multiple users for overlapping times, and assign prioritization labels for the user that you would like to notify. +You can use any calendar with an iCal address to schedule on-call times for users. During these times, notifications +configured in escalation chains with the **Notify users from an on-call schedule** setting will be sent to the the person +scheduled. You can also schedule multiple users for overlapping times, and assign prioritization labels for the user +that you would like to notify. -When you create a schedule, you will be able to select a Slack channel, associated with your OnCall account, that will notify users when there are errors or notifications regarding the assigned on-call shifts. +When you create a schedule, you will be able to select a Slack channel, associated with your OnCall account, that will +notify users when there are errors or notifications regarding the assigned on-call shifts. ## Create an on-call schedule calendar Create a primary calendar and an optional override calendar to schedule on-call shifts for team members. 1. In the **Scheduling** section of Grafana OnCall, click **+ Create schedule**. - 1. Give the schedule a name. - -1. Create a new calendar in your calendar service and locate the secret iCal URL. For example, in a Google calendar, this URL can be found in **Settings > Settings for my calendars > Integrate calendar**. - +1. Create a new calendar in your calendar service and locate the secret iCal URL. For example, in a Google calendar, + this URL can be found in **Settings > Settings for my calendars > Integrate calendar**. 1. Copy the secret iCal URL. In OnCall, paste it into the **Primary schedule for iCal URL** field. The permissions you set when you create the calendar determine who can modify the calendar. - 1. Click **Create Schedule**. - 1. Schedule on-call times for team members. - Use the Grafana username of team members as the event name to schedule their on-call times. You can take advantage of all of the features of your calendar service. + Use the Grafana username of team members as the event name to schedule their on-call times. You can take advantage + of all of the features of your calendar service. 1. Create overlapping schedules (optional). - When you create schedules that overlap, you can prioritize a schedule by adding a level marker. For example, if users AliceGrafana and BobGrafana have overlapping schedules, but BobGrafana is the primary contact, you would name his event `[L1] BobGrafana`, AliceGrafana maintains the default `[L0]` status, and would not receive notifications during the overlapping time. You can prioritize up to and including a level 9 prioritization, or `[L9]`. + When you create schedules that overlap, you can prioritize a schedule by adding a level marker. For example, if users + AliceGrafana and BobGrafana have overlapping schedules, but BobGrafana is the primary contact, you would name his + event `[L1] BobGrafana`, AliceGrafana maintains the default `[L0]` status, and would not receive notifications during + the overlapping time. You can prioritize up to and including a level 9 prioritization, or `[L9]`. # Create an override calendar (optional) -You can use an override calendar to allow team members to schedule on-call duties that will override the primary schedule. An override option allows flexibility without modifying the primary schedule. Events scheduled on the override calendar will always override overlapping events on the primary calendar. +You can use an override calendar to allow team members to schedule on-call duties that will override the primary schedule. +An override option allows flexibility without modifying the primary schedule. Events scheduled on the override calendar +will always override overlapping events on the primary calendar. 1. Create a new calendar using the same calendar service you used to create the primary calendar. Be sure to set permissions that allow team members to edit the calendar. 1. In the scheduling section of Grafana OnCall, select the primary calendar you want to override. - 1. Click **Edit**. - 1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**. diff --git a/docs/sources/configure-user-settings/_index.md b/docs/sources/configure-user-settings/_index.md index afcefb69..fcb79388 100644 --- a/docs/sources/configure-user-settings/_index.md +++ b/docs/sources/configure-user-settings/_index.md @@ -16,15 +16,20 @@ weight: 1300 # Manage users and teams for Grafana OnCall -Grafana OnCall is configured based on the teams you've created on the organization level of your Grafana instance, in **Configuration > Teams**. Administrators can create a different configuration for each team, and can navigate between team configurations in the **Select Team** dropdown menu in the **Incidents** section of Grafana OnCall. +Grafana OnCall is configured based on the teams you've created on the organization level of your Grafana instance, +in **Configuration > Teams**. Administrators can create a different configuration for each team, and can navigate +between team configurations in the **Select Team** dropdown menu in the **Incidents** section of Grafana OnCall. Users can edit their contact information, but user permissions are assigned at the Cloud portal level. ## Configure user notification policies -Administrators can configure how each user will receive notifications when they are are scheduled to receive them in escalation chains. Users can verify phone numbers and email addresses. +Administrators can configure how each user will receive notifications when they are are scheduled to receive them in +escalation chains. Users can verify phone numbers and email addresses. Only users with the **Admin** or **Editor** role +are allowed to get notifications. -> **NOTE**: You cannot add users or manage permissions in Grafana OnCall. Most user settings are found on the organizational level of your Grafana instance in **Configuration > Users**. +> **NOTE**: You cannot add users or manage permissions in Grafana OnCall. Most user settings are found on the +> organizational level of your Grafana instance in **Configuration > Users**. 1. Find users. @@ -32,17 +37,21 @@ Administrators can configure how each user will receive notifications when they 1. Configure user settings. - Add and verify a phone number, a Slack username, and a Telegram account if you want to receive notifications using these mediums. + Add and verify a phone number, a Slack username, and a Telegram account if you want to receive notifications + using these mediums. - > **NOTE:** To edit a user's profile username, email, or role, you must do so in the **Users** tab in the **Configuration** menu of your Grafana instance. + > **NOTE:** To edit a user's profile username, email, or role, you must do so in the **Users** tab in + > the **Configuration** menu of your Grafana instance. 1. Configure notification settings. - Specify the notification medium and frequency for each user. Notification steps will be followed in the order they are listed. + Specify the notification medium and frequency for each user. Notification steps will be followed in the order + they are listed. The settings you specify in **Default Notifications** dictate how a user is notified for most escalation thresholds. - **Important Notifications** are labeled in escalation chains. If an escalation event is marked as an important notification, it will bypass **Default Notification** settings and notify the user by the method specified. + **Important Notifications** are labeled in escalation chains. If an escalation event is marked as an important notification, + it will bypass **Default Notification** settings and notify the user by the method specified. ## Configure Telegram user settings in OnCall @@ -50,7 +59,8 @@ Administrators can configure how each user will receive notifications when they 1. Click **Connect automatically** for the bot to message you and to bring up your telegram account. 1. Click **Start** when the OnCall bot messages you. -If you want to connect manually, you can click the URL provided and then **SEND MESSAGE**. In your Telegram account, click **Start**. +If you want to connect manually, you can click the URL provided and then **SEND MESSAGE**. In your Telegram account, +click **Start**. ## Configure Slack user settings in OnCall diff --git a/docs/sources/escalation-policies/_index.md b/docs/sources/escalation-policies/_index.md index ceb70476..02dc9d96 100644 --- a/docs/sources/escalation-policies/_index.md +++ b/docs/sources/escalation-policies/_index.md @@ -10,7 +10,8 @@ weight: 700 Escalation chains and routes for Grafana OnCall -Administrators can create escalation policies to automatically send alert group notifications to recipients. These policies define how, where, and when to send notifications. +Administrators can create escalation policies to automatically send alert group notifications to recipients. +These policies define how, where, and when to send notifications. See the following topics for more information: diff --git a/docs/sources/escalation-policies/configure-escalation-chains/index.md b/docs/sources/escalation-policies/configure-escalation-chains/index.md index 9c19414b..a5cb85bf 100644 --- a/docs/sources/escalation-policies/configure-escalation-chains/index.md +++ b/docs/sources/escalation-policies/configure-escalation-chains/index.md @@ -16,7 +16,9 @@ weight: 100 # Configure and manage Escalation Chains -Escalation policies dictate how users and groups are notified when an alert notification is created. They can be very simple, or very complex. You can define as many escalation configurations for an integration as you need, and you can send notifications for certain alerts to a designated place when certain conditions are met, or not met. +Escalation policies dictate how users and groups are notified when an alert notification is created. They can be very +simple, or very complex. You can define as many escalation configurations for an integration as you need, and you can +send notifications for certain alerts to a designated place when certain conditions are met, or not met. Escalation policies have three main parts: @@ -26,8 +28,12 @@ Escalation policies have three main parts: ## Escalation chains -An escalation chain can have many steps, or only one step. For example, steps can be configured to notify multiple users in some order, notify users that are scheduled for on-call shifts, ping groups in Slack, use outgoing webhooks to integrate with other services, such as JIRA, and do a number of other automated notification tasks. +An escalation chain can have many steps, or only one step. For example, steps can be configured to notify multiple users +in some order, notify users that are scheduled for on-call shifts, ping groups in Slack, use outgoing webhooks to +integrate with other services, such as JIRA, and do a number of other automated notification tasks. ## Routes -An escalation workflow can employ **routes** that administrators can configure to filter alerts by regular expressions in their payloads. Notifications for these alerts can be sent to individuals, or they can make use of a new or existing escalation chain. +An escalation workflow can employ **routes** that administrators can configure to filter alerts by regular expressions +in their payloads. Notifications for these alerts can be sent to individuals, or they can make use of a new +or existing escalation chain. diff --git a/docs/sources/escalation-policies/configure-routes/index.md b/docs/sources/escalation-policies/configure-routes/index.md index 5de0a1a4..71946e07 100644 --- a/docs/sources/escalation-policies/configure-routes/index.md +++ b/docs/sources/escalation-policies/configure-routes/index.md @@ -20,35 +20,49 @@ Set up escalation chains and routes to configure escalation behavior for alert g ## Configure escalation chains -You can create and edit escalation chains in two places: within **Integrations**, by clicking on an integration tile, and in **Escalation Chains**. The following steps are for the **Integrations** workflow, but are generally applicable in both situations. +You can create and edit escalation chains in two places: within **Integrations**, by clicking on an integration tile, +and in **Escalation Chains**. The following steps are for the **Integrations** workflow, but are generally applicable +in both situations. -You can use **escalation chains** and **routes** to determine ordered escalation procedures. Escalation chains allow you to set up a series of alert group notification actions that trigger if certain conditions that you specify are met or not met. +You can use **escalation chains** and **routes** to determine ordered escalation procedures. Escalation chains allow +you to set up a series of alert group notification actions that trigger if certain conditions that you specify are +met or not met. 1. Click on the integration tile for which you want to define escalation policies. The **Escalations** section for the notification is in the pane to the right of the list of notifications. - You can click **Change alert template and grouping** to customize the look of the alert. You can also do this by clicking the **Settings** (gear) icon in the integration tile. + You can click **Change alert template and grouping** to customize the look of the alert. You can also do this by + clicking the **Settings** (gear) icon in the integration tile. 1. Create an escalation chain. - In the escalation pane, click **Escalate to** to choose from previously added escalation chains, or create a new one by clicking **Make a copy** or **Create a new chain**. This will be the name of the escalation policy you define. + In the escalation pane, click **Escalate to** to choose from previously added escalation chains, or create a new one + by clicking **Make a copy** or **Create a new chain**. This will be the name of the escalation policy you define. 1. Add escalation steps. - Click **Add escalation step** to choose from a set of actions and specify their triggering conditions. By default, the first step is to notify a slack channel or user. Specify users or channels or toggle the switch to turn this step off. + Click **Add escalation step** to choose from a set of actions and specify their triggering conditions. By default, the + first step is to notify a slack channel or user. Specify users or channels or toggle the switch to turn this step off. - To mark an escalation as **Important**, select the option from the step **Start** dropdown menu. User notification policies can be separately defined for **Important** and **Default** escalations. + To mark an escalation as **Important**, select the option from the step **Start** dropdown menu. User notification + policies can be separately defined for **Important** and **Default** escalations. ## Create a route To add a route, click **Add Route**. -You can set up a single route and specify notification escalation steps, or you can add multiple routes, each with its own configuration. +You can set up a single route and specify notification escalation steps, or you can add multiple routes, each with +its own configuration. -Each route added to an escalation policy follows an `IF`, `ELSE IF`, or `ELSE` path and depends on the type of alert you specify using a regular expression that matches content in the payload body of the alert. You can also specify where to send the notification for each route. +Each route added to an escalation policy follows an `IF`, `ELSE IF`, or `ELSE` path and depends on the type of alert you +specify using a regular expression that matches content in the payload body of the alert. You can also specify where +to send the notification for each route. -For example, you can send notifications for alerts with `\"severity\": \"critical\"` in the payload to an escalation chain called `Bob_OnCall`. You can create a different route for alerts with the payload `\"namespace\" *: *\"synthetic-monitoring-dev-.*\"` and select a escalation chain called `NotifySecurity`. +For example, you can send notifications for alerts with `\"severity\": \"critical\"` in the payload to an escalation +chain called `Bob_OnCall`. You can create a different route for alerts with the payload +`\"namespace\" *: *\"synthetic-monitoring-dev-.*\"` and select a escalation chain called `NotifySecurity`. You can set up escalation steps for each route in a chain. -> **NOTE:** When you modify an escalation chain or a route, it will modify that escalation chain across all integrations that use it. +> **NOTE:** When you modify an escalation chain or a route, it will modify that escalation chain across +> all integrations that use it. diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index b7dc6e3e..0a4b8033 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -13,74 +13,92 @@ weight: 300 # Get started with Grafana OnCall -Grafana OnCall is an incident response tool built to help DevOps and SRE teams improve their collaboration and resolve incidents faster. +Grafana OnCall is an incident response tool built to help DevOps and SRE teams improve their collaboration and resolve +incidents faster. -With a centralized view of all your alerts, automated alert escalation and grouping, and on-call scheduling, Grafana OnCall helps ensure that alert notifications reach the right people, at the right time using the right notification method. +With a centralized view of all your alerts, automated alert escalation and grouping, and on-call scheduling, Grafana +OnCall helps ensure that alert notifications reach the right people, at the right time using the right notification method. The following diagram details an example alert workflow with Grafana OnCall: -These procedures introduce you to initial Grafana OnCall configuration steps, including monitoring system integration, how to set up escalation chains, and how to use your calendar service for on-call scheduling. - +These procedures introduce you to initial Grafana OnCall configuration steps, including monitoring system integration, +how to set up escalation chains, and how to use your calendar service for on-call scheduling. ## Before you begin -Grafana OnCall is available for Grafana Cloud as well as Grafana open source users. You must have a Grafana Cloud account or use [Open Source Grafana OnCall]({{< relref "../open-source" >}}) +Grafana OnCall is available for Grafana Cloud as well as Grafana open source users. You must have a Grafana Cloud account +or use [Open Source Grafana OnCall]({{< relref "../open-source" >}}) ## Install Open Source Grafana OnCall -For Open Source Grafana OnCall installation guidance, refer to [Open Source Grafana OnCall]({{< relref "../open-source" >}}) - ->**Note:** If you are using Grafana OnCall with your Grafana Cloud instance there are no install steps. Access Grafana OnCall from your Grafana Cloud account and skip ahead to “Get alerts into Grafana OnCall” +For Open Source Grafana OnCall installation guidance, refer to +[Open Source Grafana OnCall]({{< relref "../open-source" >}}) +> **Note:** If you are using Grafana OnCall with your Grafana Cloud instance there are no install steps. Access Grafana +> OnCall from your Grafana Cloud account and skip ahead to “Get alerts into Grafana OnCall” ## Get alerts into Grafana OnCall -Once you’ve installed Grafana OnCall or accessed it from your Grafana Cloud instance, you can begin integrating with monitoring systems, configuring escalation chains, and get alerts into Grafana OnCall. +Once you’ve installed Grafana OnCall or accessed it from your Grafana Cloud instance, you can begin integrating with +monitoring systems, configuring escalation chains, and get alerts into Grafana OnCall. ### Integrate with a monitoring system -Regardless of where your alerts originate, you can send them to Grafana OnCall via available integrations or customizable webhooks. To start receiving alerts in Grafana OnCall, use the following steps to configure your first integration and send a demo alert. +Regardless of where your alerts originate, you can send them to Grafana OnCall via available integrations or customizable +webhooks. To start receiving alerts in Grafana OnCall, use the following steps to configure your first integration and +send a demo alert. + +#### Configure your first integration -#### Configure your first integration: 1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration for receiving alerts**. 2. Select an integration from the provided options, if the integration you’re looking for isn’t listed, then select Webhook. 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your monitoring system to send alerts to Grafana OnCall. - #### Send a demo alert 1. In the integration tab, click **Send demo alert** then navigate to the **Alert Groups** tab to see your test alert firing. 2. Explore the alert by clicking on the title of the alert. 3. Acknowledge and resolve the test alert. -For more information on Grafana OnCall integrations and further configuration guidance, refer to, [Grafana OnCall integrations]({{< relref "../integrations" >}}) - +For more information on Grafana OnCall integrations and further configuration guidance, refer to +[Grafana OnCall integrations]({{< relref "../integrations" >}}) ### Configure Escalation Chains -Escalation Chains are customizable automated alert routing steps that enable you to specify who is notified for a certain alert. In addition to escalation chains, you can configure Routes to send alerts to different escalation chains depending on the alert details. +Escalation Chains are customizable automated alert routing steps that enable you to specify who is notified for a certain +alert. In addition to escalation chains, you can configure Routes to send alerts to different escalation chains depending +on the alert details. -Once your integration is configured, you can set up an escalation chain to determine how alerts from your integration are handled. Multi-step escalation chains help ensure thorough alert escalation to prevent alerts from being missed. +Once your integration is configured, you can set up an escalation chain to determine how alerts from your integration +are handled. Multi-step escalation chains help ensure thorough alert escalation to prevent alerts from being missed. To configure Escalation Chains: + 1. Navigate to the **Escalation Chains** tab and click **+ New Escalation Chain** 2. Give your Escalation Chain a useful name and click **Create** 3. Add a series of escalation steps from the available dropdown options. -4. To link your Escalation Chain to your integration, navigate back to the **Integrations tab**, Select your newly created Escalation Chain from the “**Escalate to**” dropdown. +4. To link your Escalation Chain to your integration, navigate back to the **Integrations tab**, Select your newly + created Escalation Chain from the “**Escalate to**” dropdown. Alerts from this integration will now follow the escalation steps configured in your Escalation Chain. -For more information on Escalation Chains and more ways to customize them, refer to [Configure and manage Escalation Chains]({{< relref "../escalation-policies/configure-escalation-chains" >}}) +For more information on Escalation Chains and more ways to customize them, refer to +[Configure and manage Escalation Chains]({{< relref "../escalation-policies/configure-escalation-chains" >}}) ## Get notified of an alert -In order for Grafana OnCall to notify you of an alert, you must configure how you want to be notified. Personal notification policies, chatops integrations, and on-call schedules allow you to automate how users are notified of alerts. +In order for Grafana OnCall to notify you of an alert, you must configure how you want to be notified. Personal notification +policies, chatops integrations, and on-call schedules allow you to automate how users are notified of alerts. ### Configure personal notification policies -Personal notification policies determine how a user is notified for a certain type of alert. Get notified by SMS, phone call, or Slack mentions. Administrators can configure how users receive notification for certain types of alerts. For more information on personal notification policies, refer to [Manage users and teams for Grafana OnCall]({{< relref "../configure-user-settings" >}}) + +Personal notification policies determine how a user is notified for a certain type of alert. Get notified by SMS, +phone call, or Slack mentions. Administrators can configure how users receive notification for certain types of alerts. +For more information on personal notification policies, refer to +[Manage users and teams for Grafana OnCall]({{< relref "../configure-user-settings" >}}) To configure users personal notification policies: @@ -88,10 +106,11 @@ To configure users personal notification policies: 2. Select a user from the user list and click **Edit** 3. Configure **Default Notifications** and **Important Notification** - ### Configure Slack for Grafana OnCall -Grafana OnCall integrates closely with your Slack workspace to deliver alert notifications to individuals, user groups, and channels. Slack notifications can be triggered by steps in an escalation chain or as a step in users personal notification policies. +Grafana OnCall integrates closely with your Slack workspace to deliver alert notifications to individuals, user groups, +and channels. Slack notifications can be triggered by steps in an escalation chain or as a step in users personal +notification policies. To configure Slack for Grafana OnCall: @@ -102,12 +121,13 @@ To configure Slack for Grafana OnCall: 5. Click Allow to allow Grafana OnCall to access Slack. 6. Ensure users verify their Slack accounts in their user profile in Grafana OnCall. -For further instruction on connecting to your Slack workspace, refer to [Slack integration for Grafana OnCall]({{< relref "../integrations/chatops-integrations/configure-slack/" >}}) - +For further instruction on connecting to your Slack workspace, refer to +[Slack integration for Grafana OnCall]({{< relref "../integrations/chatops-integrations/configure-slack/" >}}) ### Add your on-call schedule -Grafana OnCall allows you to manage your on-call schedule in your preferred calendar app such as Google Calendar or Microsoft Outlook. +Grafana OnCall allows you to manage your on-call schedule in your preferred calendar app such as Google Calendar or +Microsoft Outlook. To integrate your on-call calendar with Grafana OnCall: @@ -116,6 +136,5 @@ To integrate your on-call calendar with Grafana OnCall: 3. Copy the iCal URL associated with your on-call calendar from your calendar integration settings. 4. Configure the rest of the schedule settings and click Create Schedule -For more information on on-call schedules, refer to [Configure and manage on-call schedules]({{< relref "../calendar-schedules" >}}) - - +For more information on on-call schedules, refer to +[Configure and manage on-call schedules]({{< relref "../calendar-schedules" >}}) diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index a49e7496..8d6247ac 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -16,13 +16,18 @@ weight: 500 # Grafana OnCall integrations -Integrations allow you to connect monitoring systems of your choice to send alerts to Grafana OnCall. Regardless of where your alerts originate, you can configure alerts to be sent to Grafana OnCall for alert escalation and notification. Grafana OnCall receives alerts in JSON format via a POST request, OnCall then parses alert data using preconfigured alert templates to determine alert grouping, apply routes, and determine correct escalation. +Integrations allow you to connect monitoring systems of your choice to send alerts to Grafana OnCall. Regardless of where +your alerts originate, you can configure alerts to be sent to Grafana OnCall for alert escalation and notification. +Grafana OnCall receives alerts in JSON format via a POST request, OnCall then parses alert data using preconfigured +alert templates to determine alert grouping, apply routes, and determine correct escalation. -There are many integrations that are directly supported by Grafana OnCall. Those that aren’t currently listed in the Integrations menu can be connected using the webhook integration and configured alert templates. +There are many integrations that are directly supported by Grafana OnCall. Those that aren’t currently listed in the +Integrations menu can be connected using the webhook integration and configured alert templates. ## Configure and manage integrations -You can configure and manage your integrations from the **Integrations** tab in Grafana OnCall. The following sections describe how to configure and customize your integrations to ensure alerts are treated appropriately. +You can configure and manage your integrations from the **Integrations** tab in Grafana OnCall. The following sections +describe how to configure and customize your integrations to ensure alerts are treated appropriately. ### Connect an integration to Grafana OnCall @@ -35,7 +40,8 @@ To configure an integration for Grafana OnCall: ### Manage Grafana OnCall integrations -To manage existing integrations, navigate to the **Integrations** tab in Grafana OnCall and select the integration you want to manage. +To manage existing integrations, navigate to the **Integrations** tab in Grafana OnCall and select the integration +you want to manage. #### Customize alert templates and grouping @@ -50,9 +56,11 @@ To customize alert grouping for an integration: 1. Click **Change alert template and grouping**. 2. Select **Alert Behavior** from the dropdown menu next to **Edit template for**. -3. Edit the **grouping id**, **acknowledge condition**, and **resolve condition** templates as needed to customize your alert behavior. +3. Edit the **grouping id**, **acknowledge condition**, and **resolve condition** templates as needed to customize + your alert behavior. -For more information on alert templates, see [Configure alerts templates]({{< relref "../alert-behavior/alert-templates" >}}) +For more information on alert templates, see +[Configure alerts templates]({{< relref "../alert-behavior/alert-templates" >}}) #### Add Routes @@ -62,7 +70,8 @@ To add a route to an integration using regular expression: 2. Click **+ Add Route**. 3. Use python style regex to match on your alert content. 4. Click **Create Route**. -5. Select an escalation chain for “**IF** alert payload matches regex” and “**ELSE**” to specify where to route each type of alert. +5. Select an escalation chain for “**IF** alert payload matches regex” and “**ELSE**” to specify where to route each + type of alert. #### Edit integration name diff --git a/docs/sources/integrations/available-integrations/_index.md b/docs/sources/integrations/available-integrations/_index.md index 3d02ba76..4babb8e2 100644 --- a/docs/sources/integrations/available-integrations/_index.md +++ b/docs/sources/integrations/available-integrations/_index.md @@ -15,11 +15,15 @@ weight: 100 # Available integrations -Grafana OnCall can connect directly to the monitoring services where your alerts originate. All currently available integrations are listed in the Grafana OnCall **Create Integration** section. +Grafana OnCall can connect directly to the monitoring services where your alerts originate. All currently available +integrations are listed in the Grafana OnCall **Create Integration** section. -If the integration you're looking for isn't currently listed, see [Webhook integrations for Grafana OnCall]({{< relref "../available-integrations/configure-webhook" >}}) to integration your monitoring system with Grafana OnCall. +If the integration you're looking for isn't currently listed, see +[Webhook integrations for Grafana OnCall]({{< relref "../available-integrations/configure-webhook" >}}) to integration +your monitoring system with Grafana OnCall. -> **Note:** Some integrations are available for Grafana Cloud instances only. See individual integration guides for more information. +> **Note:** Some integrations are available for Grafana Cloud instances only. See individual integration +> guides for more information. The following integrations are currently available for Grafana OnCall and have documentation: diff --git a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md index e2569ed0..a56cc498 100644 --- a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md +++ b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md @@ -18,16 +18,15 @@ weight: 300 The Alertmanager integration for Grafana OnCall handles alerts sent by client applications such as the Prometheus server. -Grafana OnCall provides grouping abilities when processing alerts from Alertmanager, including initial deduplicating, grouping, and routing the alerts to Grafana OnCall. +Grafana OnCall provides grouping abilities when processing +alerts from Alertmanager, including initial deduplicating, grouping, and routing the alerts to Grafana OnCall. ## Configure Alertmanager integration for Grafana OnCall You must have an Admin role to create integrations in Grafana OnCall. 1. In the **Integrations** tab, click **+ New integration for receiving alerts**. - 2. Select **Alertmanager** from the list of available integrations. - 3. Follow the instructions in the **How to connect** window to get your unique integration URL and identify next steps. @@ -36,16 +35,16 @@ You must have an Admin role to create integrations in Grafana OnCall. Update the `receivers` section of your Alertmanager configuration to use a unique integration URL: -``` +```yaml route: - receiver: 'oncall' + receiver: "oncall" group_by: [alertname, datacenter, app] receivers: -- name: 'oncall' - webhook_configs: - - url: - send_resolved: true + - name: "oncall" + webhook_configs: + - url: + send_resolved: true ``` ## Configure grouping with Alertmanager and Grafana OnCall @@ -55,14 +54,19 @@ You can use the alert grouping mechanics of Alertmanager and Grafana OnCall to c Alertmanager offers three alert grouping options: - `group_by` provides two options, `instance` or `job`. -- `group_wait` sets the length of time to initially wait before sending a notification for a particular group of alerts. For example, `group_wait` can be set to 45s. +- `group_wait` sets the length of time to initially wait before sending a notification for a particular group of alerts. + For example, `group_wait` can be set to 45s. - Setting a high value for `group_wait` reduces alert noise and minimizes interruption, but it may introduce delays in receiving alert notifications. To set an appropriate wait time, consider whether the group of alerts will be the same as those previously sent. + Setting a high value for `group_wait` reduces alert noise and minimizes interruption, but it may introduce delays in + receiving alert notifications. To set an appropriate wait time, consider whether the group of alerts will be the same + as those previously sent. -- `group_interval` sets the length of time to wait before sending notifications about new alerts that have been added to a group of alerts that have been previously alerted on. This setting is usually set to five minutes or more. +- `group_interval` sets the length of time to wait before sending notifications about new alerts that have been added to + a group of alerts that have been previously alerted on. This setting is usually set to five minutes or more. - During high alert volume periods, Alertmanager will send alerts at each `group_interval`, which can mean a lot of distraction. Grafana OnCall grouping will help manage this in the following ways: + During high alert volume periods, Alertmanager will send alerts at each `group_interval`, which can mean a lot of + distraction. Grafana OnCall grouping will help manage this in the following ways: - Grafana OnCall groups alerts based on the first label of each alert. - - - Grafana OnCall marks an incident as resolved only when the amount of grouped alerts with state `resolved` equals the amount of alerts with state `firing`. + - Grafana OnCall marks an incident as resolved only when the amount of grouped alerts with state `resolved` equals + the amount of alerts with state `firing`. diff --git a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md index c3197649..5bd8ae9c 100644 --- a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md +++ b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md @@ -18,53 +18,53 @@ weight: 100 Grafana Alerting for Grafana OnCall can be set up using two methods: - Grafana Alerting: Grafana OnCall is connected to the same Grafana instance being used to manage Grafana OnCall. - -- Grafana (Other Grafana): Grafana OnCall is connected to one or more Grafana instances separate from the one being used to manage Grafana OnCall. +- Grafana (Other Grafana): Grafana OnCall is connected to one or more Grafana instances separate from the one being + used to manage Grafana OnCall. ## Configure Grafana Alerting for Grafana OnCall You must have an Admin role to create integrations in Grafana OnCall. 1. In the **Integrations** tab, click **+ New integration for receiving alerts**. - -2. Select **Grafana Alerting** by clicking the **Quick connect** button or select **Grafana (Other Grafana)** from the integrations list. - -3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL and complete any necessary configurations. +2. Select **Grafana Alerting** by clicking the **Quick connect** button or select **Grafana (Other Grafana)** from + the integrations list. +3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL + and complete any necessary configurations. ### Configure Grafana Cloud Alerting -Use the following method if you are connecting Grafana OnCall with alerts coming from the same Grafana instance from which Grafana OnCall is being managed. +Use the following method if you are connecting Grafana OnCall with alerts coming from the same Grafana instance from +which Grafana OnCall is being managed. 1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. +1. Click **Quick connect** in the **Grafana Alerting** tile. This will automatically create the integration in Grafana + OnCall as well as the required contact point in Alerting. -1. Click **Quick connect** in the **Grafana Alerting** tile. This will automatically create the integration in Grafana OnCall as well as the required contact point in Alerting. - - > **Note:** You must connect the contact point with a notification policy. For more information, see [Contact points in Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/contact-points/) - -1. Determine the escalation chain for the new integration by either selecting an existing one or by creating a new escalation chain. - -1. In Grafana Cloud Alerting, navigate to **Alerting > Contact Points** and find a contact point with a name matching the integration you created in Grafana OnCall. + > **Note:** You must connect the contact point with a notification policy. For more information, see + > [Contact points in Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/contact-points/) +1. Determine the escalation chain for the new integration by either selecting an existing one or by creating a new + escalation chain. +1. In Grafana Cloud Alerting, navigate to **Alerting > Contact Points** and find a contact point with a name matching + the integration you created in Grafana OnCall. 1. Click the **Edit** (pencil) icon, then click **Test**. This will send a test alert to Grafana OnCall. ### Configure Grafana (Other Grafana) -Connect Grafana OnCall with alerts coming from a Grafana instance that is different from the instance that Grafana OnCall is being managed: +Connect Grafana OnCall with alerts coming from a Grafana instance that is different from the instance that Grafana +OnCall is being managed: 1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. - 2. Select the **Grafana (Other Grafana)** tile. - -3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL and complete any necessary configurations. - -4. Determine the escalation chain for the new integration by either selecting an existing one or by creating a new escalation chain. - +3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL + and complete any necessary configurations. +4. Determine the escalation chain for the new integration by either selecting an existing one or by creating a + new escalation chain. 5. Go to the other Grafana instance to connect to Grafana OnCall and navigate to **Alerting > Contact Points**. - 6. Select **New Contact Point**. - 7. Choose the contact point type `webhook`, then paste the URL generated in step 3 into the URL field. - > **Note:** You must connect the contact point with a notification policy. For more information, see [Contact points in Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/contact-points/). + > **Note:** You must connect the contact point with a notification policy. For more information, + > see [Contact points in Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/contact-points/). 8. Click the **Edit** (pencil) icon, then click **Test**. This will send a test alert to Grafana OnCall. diff --git a/docs/sources/integrations/available-integrations/configure-webhook/index.md b/docs/sources/integrations/available-integrations/configure-webhook/index.md index a762f006..5319cdc6 100644 --- a/docs/sources/integrations/available-integrations/configure-webhook/index.md +++ b/docs/sources/integrations/available-integrations/configure-webhook/index.md @@ -16,14 +16,16 @@ weight: 700 # Webhook integrations for Grafana OnCall -Grafana OnCall directly supports many integrations, those that aren’t currently listed in the Integrations menu can be connected using the webhook integration and configured alert templates. +Grafana OnCall directly supports many integrations, those that aren’t currently listed in the Integrations menu can be +connected using the webhook integration and configured alert templates. With the webhook integration, you can connect to any alert source that isn't listed in the **Create Integration** page. There are two available formats, **Webhook** and **Formatted Webhook**. - **Webhook** will pull all of the raw JSON payload and display it in the manner that it's received. -- **Formatted Webhook** can be used if the alert payload sent by your monitoring service is formatted in a way that OnCall recognizes. +- **Formatted Webhook** can be used if the alert payload sent by your monitoring service is formatted in a way that + OnCall recognizes. The following fields are recognized, but none are required: @@ -39,7 +41,8 @@ To configure a webhook integration: 1. In the **Integrations** tab, click **+ New integration for receiving alerts**. 2. Select either **Webhook** or **Formatted Webhook** integration. 3. Follow the configuration steps in the **How to connect** section of the integration settings. -4. Use the unique webhook URL to complete any configuration in your monitoring service to send POST requests. Use any http client, e.g. curl to send POST requests with any payload. +4. Use the unique webhook URL to complete any configuration in your monitoring service to send POST requests. Use any + http client, e.g. curl to send POST requests with any payload. For example: @@ -57,4 +60,5 @@ For example: }' ``` -To learn how to use custom alert templates for formatted webhooks, see [Configure alerts templates]({{< relref "../../../alert-behavior/alert-templates/" >}}). +To learn how to use custom alert templates for formatted webhooks, see +[Configure alerts templates]({{< relref "../../../alert-behavior/alert-templates/" >}}). diff --git a/docs/sources/integrations/available-integrations/configure-zabbix/index.md b/docs/sources/integrations/available-integrations/configure-zabbix/index.md index 7fc0bb8e..86d4f8a7 100644 --- a/docs/sources/integrations/available-integrations/configure-zabbix/index.md +++ b/docs/sources/integrations/available-integrations/configure-zabbix/index.md @@ -15,7 +15,9 @@ weight: 500 # Zabbix integration for Grafana OnCall -Zabbix is an open-source monitoring software tool for diverse IT components, including networks, servers, virtual machines, and cloud services. Zabbix provides monitoring for metrics such as network utilization, CPU load, and disk space consumption. +Zabbix is an open-source monitoring software tool for diverse IT components, including networks, servers, virtual +machines, and cloud services. Zabbix provides monitoring for metrics such as network utilization, CPU load, and disk +space consumption. ## Configure Zabbix integration for Grafana OnCall @@ -44,13 +46,15 @@ This integration is available for Grafana Cloud OnCall. You must have an Admin r docker exec -it zabbix-appliance bash ``` -1. Place the [grafana_oncall.sh](#grafana_oncallsh-script) script in the `AlertScriptsPath` directory specified within the Zabbix server configuration file (zabbix_server.conf). +1. Place the [grafana_oncall.sh](#grafana_oncallsh-script) script in the `AlertScriptsPath` directory specified within + the Zabbix server configuration file (zabbix_server.conf). ```bash grep AlertScriptsPath /etc/zabbix/zabbix_server.conf ``` - > **Note:** The script must be executable by the user running the zabbix_server binary (usually "zabbix") on the Zabbix server. For example, `chmod +x grafana_oncall.sh` + > **Note:** The script must be executable by the user running the zabbix_server binary (usually "zabbix") on the + > Zabbix server. For example, `chmod +x grafana_oncall.sh` ```bash ls -lh /usr/lib/zabbix/alertscripts/grafana_oncall.sh @@ -92,6 +96,7 @@ To send alerts to Grafana OnCall, the {ALERT.SEND_TO} value must be set in the [ 1. Specify **Send to** OnCall using the unique integration URL from the above step in the testing window that opens. Create a test message with a body and optional subject and click **Test**. @@ -103,9 +108,11 @@ Use the following procedure to configure grouping and auto-resolve. 1. Provide a parameter as an identifier for group differentiation to Grafana OnCall. 1. Append that variable to the subject of the action as `ONCALL_GROUP: ID`, where `ID` is any of the Zabbix [macros](https://www.zabbix.com/documentation/4.2/manual/appendix/macros/supported_by_location). - For example, `{EVENT.ID}`. The Grafana OnCall script [grafana_oncall.sh](#grafana_oncallsh-script) extracts this event and passes the `alert_uid` to Grafana OnCall. + For example, `{EVENT.ID}`. The Grafana OnCall script [grafana_oncall.sh](#grafana_oncallsh-script) extracts this event + and passes the `alert_uid` to Grafana OnCall. -1. To enable auto-resolve within Grafana Oncall, the "Resolved" keyword is required in the **Default subject** field in **Recovered operations**. +1. To enable auto-resolve within Grafana Oncall, the "Resolved" keyword is required in the **Default subject** field + in **Recovered operations**. diff --git a/docs/sources/integrations/chatops-integrations/_index.md b/docs/sources/integrations/chatops-integrations/_index.md index c96e8d6e..038042aa 100644 --- a/docs/sources/integrations/chatops-integrations/_index.md +++ b/docs/sources/integrations/chatops-integrations/_index.md @@ -17,7 +17,9 @@ weight: 300 # Available ChatOps integrations -Grafana OnCall directly supports the export of alert notifications to some popular messaging applications like Slack and Telegram. You can use outgoing webhooks to applications that aren't directly supported. For information on configuring outgoing webhooks, see [Send alert group notifications by webhook]({{< relref "../../alert-behavior/outgoing-webhooks/" >}}). +Grafana OnCall directly supports the export of alert notifications to some popular messaging applications like Slack and +Telegram. You can use outgoing webhooks to applications that aren't directly supported. For information on configuring +outgoing webhooks, see [Send alert group notifications by webhook]({{< relref "../../alert-behavior/outgoing-webhooks/" >}}). To configure supported messaging apps, see the following topics: diff --git a/docs/sources/integrations/chatops-integrations/configure-slack/index.md b/docs/sources/integrations/chatops-integrations/configure-slack/index.md index 33d29016..75aff6d9 100644 --- a/docs/sources/integrations/chatops-integrations/configure-slack/index.md +++ b/docs/sources/integrations/chatops-integrations/configure-slack/index.md @@ -17,16 +17,20 @@ weight: 100 # Slack integration for Grafana OnCall -The Slack integration for Grafana OnCall incorporates your Slack workspace directly into your incident response workflow to help your team focus on alert resolution with less friction. - -Integrating your Slack workspace with Grafana OnCall allows users and teams to be notified of alerts directly in Slack with automated alert escalation steps and user notification preferences. There are a number of alert actions that users can take directly from Slack, including acknowledge, resolve, add resolution notes, and more. +The Slack integration for Grafana OnCall incorporates your Slack workspace directly into your incident response workflow +to help your team focus on alert resolution with less friction. +Integrating your Slack workspace with Grafana OnCall allows users and teams to be notified of alerts directly in Slack +with automated alert escalation steps and user notification preferences. There are a number of alert actions that users +can take directly from Slack, including acknowledge, resolve, add resolution notes, and more. ## Before you begin -To install the Slack integration, you must have Admin permissions in your Grafana instance as well as the Slack workspace that you’d like to integrate with. +To install the Slack integration, you must have Admin permissions in your Grafana instance as well as the Slack workspace +that you’d like to integrate with. -For Open Source Grafana OnCall Slack installation guidance, refer to [Open Source Grafana OnCall]({{< relref "../../../open-source/" >}}). +For Open Source Grafana OnCall Slack installation guidance, refer to +[Open Source Grafana OnCall]({{< relref "../../../open-source/" >}}). ## Install Slack integration for Grafana OnCall @@ -36,34 +40,44 @@ For Open Source Grafana OnCall Slack installation guidance, refer to [Open Sourc 4. Provide your Slack workspace URL and sign with your Slack credentials. 5. Click **Allow** to give Grafana OnCall permission to access your Slack workspace. - ## Post-install configuration for Slack integration -Configure the following additional settings to ensure Grafana OnCall alerts are routed to the intended Slack channels and users: +Configure the following additional settings to ensure Grafana OnCall alerts are routed to the intended Slack channels +and users: -1. From your **Slack integration** settings, select a default slack channel in the first dropdown menu. This is where alerts will be sent unless otherwise specified in escalation chains. -2. In **Additional Settings**, configure alert reminders for alerts to retrigger after being acknowledged for some amount of time. +1. From your **Slack integration** settings, select a default slack channel in the first dropdown menu. This is where + alerts will be sent unless otherwise specified in escalation chains. +2. In **Additional Settings**, configure alert reminders for alerts to retrigger after being acknowledged for some + amount of time. 3. Ensure all users verify their slack account in their Grafana OnCall **users info**. ### Configure Escalation Chains with Slack notifications -Once your Slack integration is configured you can configure Escalation Chains to notify via Slack messages for alerts in Grafana OnCall. -There are two Slack notification options that you can configure into escalation chains, notify whole Slack channel and notify Slack user group: +Once your Slack integration is configured you can configure Escalation Chains to notify via Slack messages for alerts +in Grafana OnCall. -1. In Grafana OnCall, navigate to the **Escalation Chains** tab then select an existing escalation chain or click **+ New escalation chain**. +There are two Slack notification options that you can configure into escalation chains, notify whole Slack channel and +notify Slack user group: + +1. In Grafana OnCall, navigate to the **Escalation Chains** tab then select an existing escalation chain or + click **+ New escalation chain**. 2. Click the dropdown for **Add escalation step**. 3. Configure your escalation chain with automated Slack notifications. ### Configure user notifications with Slack mentions + To be notified of alerts in Grafana OnCall via Slack mentions: 1. Navigate to the **Users** tab in Grafana OnCall, click **Edit** next to a user. 2. In the **User Info** tab, edit or configure notification steps by clicking + Add Notification step -3. select **Notify by** in the first dropdown and select **Slack mentions** in the second dropdown to receive alert notifications via Slack mentions. +3. select **Notify by** in the first dropdown and select **Slack mentions** in the second dropdown to receive alert + notifications via Slack mentions. ### Configure on-call notifications in Slack -The Slack integration for Grafana Oncall supports automated Slack on-call notifications that notify individuals and teams of their on-call shifts. Admins can configure shift notification behavior in Notification preferences: -1. When an on-call shift notification is sent to a person or channel, click the gear icon to access **Notifications preferences**. +The Slack integration for Grafana Oncall supports automated Slack on-call notifications that notify individuals and +teams of their on-call shifts. Admins can configure shift notification behavior in Notification preferences: + +1. When an on-call shift notification is sent to a person or channel, click the gear icon to + access **Notifications preferences**. 2. Configure on-call notifications for future shift notifications. - diff --git a/docs/sources/integrations/chatops-integrations/configure-teams/index.md b/docs/sources/integrations/chatops-integrations/configure-teams/index.md index b4f42450..6946ead1 100644 --- a/docs/sources/integrations/chatops-integrations/configure-teams/index.md +++ b/docs/sources/integrations/chatops-integrations/configure-teams/index.md @@ -18,48 +18,63 @@ weight: 500 # Microsoft Teams integration for Grafana OnCall -The Microsoft Teams integration for Grafana OnCall embeds your MS Teams channels directly into your incident response workflow to help your team focus on alert resolution. +The Microsoft Teams integration for Grafana OnCall embeds your MS Teams channels directly into your incident response +workflow to help your team focus on alert resolution. -Integrating MS Teams with Grafana OnCall allows users to be notified of alerts directly in MS Teams with automated escalation steps and user notification preferences. Users can also take action on alerts directly from MS Teams, including acknowledge, unacknowledge, resolve, and silence. +Integrating MS Teams with Grafana OnCall allows users to be notified of alerts directly in MS Teams with automated escalation +steps and user notification preferences. Users can also take action on alerts directly from MS Teams, including +acknowledge, unacknowledge, resolve, and silence. ## Before you begin ->NOTE: **This integration is available to Grafana Cloud instances of Grafana OnCall only.** + +> NOTE: **This integration is available to Grafana Cloud instances of Grafana OnCall only.** The following is required to connect to Microsoft Teams to Grafana OnCall: + - You must have Admin permissions in your Grafana Cloud instance. - You must have Owner permissions in Microsoft Teams. - Install the Grafana OnCall app from the [Microsoft Marketplace](https://appsource.microsoft.com/en-us/product/office/WA200004307). ## Install Microsoft Teams integration for Grafana OnCall + 1. From the **ChatOps** tab in Grafana OnCall, select **Microsoft Teams** in the side menu. 1. Click **+Connect Microsoft Teams channel**. -2. Follow the steps provided to connect to your Teams channels, then click **Done**. -3. To add additional teams and channels click **+Connect Microsoft Teams channel** again and repeat step 3 as needed. - +1. Follow the steps provided to connect to your Teams channels, then click **Done**. +1. To add additional teams and channels click **+Connect Microsoft Teams channel** again and repeat step 3 as needed. ## Post-install configuration for Microsoft Teams integration + Configure the following settings to ensure Grafana OnCall alerts are routed to the intended Teams channels and users: -- Set a default channel from the list of connected MS Teams channels. This is where alerts will be sent unless otherwise specified in escalation chains. +- Set a default channel from the list of connected MS Teams channels. This is where alerts will be sent unless otherwise + specified in escalation chains. - Ensure all users verify their MS Teams account in their Grafana OnCall user profile. ### Connect Microsoft Teams user to Grafana OnCall + 1. From the **Users** tab in Grafana OnCall, click **View my profile**. 1. Navigate to **Microsoft Teams username**, click **Connect**. -2. Follow the steps provided to connect your Teams user. -3. Navigate back to your Grafana OnCall profile and verify that your Microsoft Teams account is linked to your Grafana OnCall user. +1. Follow the steps provided to connect your Teams user. +1. Navigate back to your Grafana OnCall profile and verify that your Microsoft Teams account is linked to your Grafana + OnCall user. ### Configure user notifications with Microsoft Teams + To be notified of Grafana OnCall alerts via MS Teams: + 1. Navigate to the **Users** tab in Grafana OnCall, click **Edit** next to a user. 1. In the **User Info** tab, edit or configure notification steps by clicking **+Add Notification step** -1. Select **Notify by** in the first dropdown and select **Microsoft Teams** in the second dropdown to receive alert notifications in Teams. +1. Select **Notify by** in the first dropdown and select **Microsoft Teams** in the second dropdown to receive alert + notifications in Teams. ### Configure escalation chains to post to Microsoft Teams channels -Once your MS Teams integration is configured you can add an escalation step at the integration level to automatically send alerts from a specific integration to a channel in MS Teams. - + +Once your MS Teams integration is configured you can add an escalation step at the integration level to automatically +send alerts from a specific integration to a channel in MS Teams. + To automatically send alerts from an integration to MS Teams channels: -1. Navigate to the **Integrations** tab in Grafana OnCall, select an existing integration or click **+New integration for receiving alerts**. +1. Navigate to the **Integrations** tab in Grafana OnCall, select an existing integration or + click **+New integration for receiving alerts**. 1. From the integrations settings, navigate to the escalation chain panel. -1. Enable **Post to Microsoft Teams channel** by selecting a channel to connect from the dropdown. \ No newline at end of file +1. Enable **Post to Microsoft Teams channel** by selecting a channel to connect from the dropdown. diff --git a/docs/sources/integrations/chatops-integrations/configure-telegram/index.md b/docs/sources/integrations/chatops-integrations/configure-telegram/index.md index fd510340..e24d87e7 100644 --- a/docs/sources/integrations/chatops-integrations/configure-telegram/index.md +++ b/docs/sources/integrations/chatops-integrations/configure-telegram/index.md @@ -21,14 +21,16 @@ You can manage alerts either directly in your personal Telegram DMs or in a dedi ## Configure Telegram user settings in Grafana OnCall -To receive alert group contents, escalation logs and to be able to perform actions (acknowledge, resolve, silence) in Telegram DMs, please refer to the following steps: +To receive alert group contents, escalation logs and to be able to perform actions (acknowledge, resolve, silence) in +Telegram DMs, please refer to the following steps: 1. In your profile, find the Telegram setting and click **Connect**. 1. Click **Connect automatically** for the bot to message you and to bring up your telegram account. 1. Click **Start** when the OnCall bot messages you and wait for the connection confirmation. 1. Done! Now you can receive alerts directly to your Telegram DMs. -If you want to connect manually, you can click the URL provided and then **SEND MESSAGE**. In your Telegram account, click **Start**. +If you want to connect manually, you can click the URL provided and then **SEND MESSAGE**. In your Telegram account, +click **Start**. ## (Optional) Connect to a Telegram channel @@ -37,7 +39,8 @@ In case you want to manage alerts in a dedicated Telegram channel, please use th > **NOTE:** Only Grafana users with the administrator role can configure OnCall settings. 1. In OnCall, click on the **ChatOps** tab and select Telegram in the side menu. -1. Click **Connect Telegram channel** and follow the instructions, mirrored here for reference. A unique verification code will be generated that you must use to activate the channel. +1. Click **Connect Telegram channel** and follow the instructions, mirrored here for reference. A unique verification + code will be generated that you must use to activate the channel. 1. In your team Telegram account, create a new channel, and set it to **Private**. 1. In **Manage Channel**, make sure **Sign messages** is enabled. 1. Create a new discussion group. @@ -50,5 +53,7 @@ In case you want to manage alerts in a dedicated Telegram channel, please use th 1. In OnCall, send the provided verification code to the channel. 1. Make sure users connect to Telegram in their OnCall user profile. -Each alert group is assigned a dedicated discussion. Users can perform actions (acknowledge, resolve, silence), and discuss alerts in the comments section of the discussions. -In case an integration route is not configured to use a Telegram channel, users will receive messages with alert group contents, logs and actions in their DMs. +Each alert group is assigned a dedicated discussion. Users can perform actions (acknowledge, resolve, silence), and +discuss alerts in the comments section of the discussions. +In case an integration route is not configured to use a Telegram channel, users will receive messages with alert group +contents, logs and actions in their DMs. diff --git a/docs/sources/oncall-api-reference/_index.md b/docs/sources/oncall-api-reference/_index.md index cc453fec..12ae513a 100644 --- a/docs/sources/oncall-api-reference/_index.md +++ b/docs/sources/oncall-api-reference/_index.md @@ -27,7 +27,8 @@ curl "api_endpoint_here" --header "Authorization: "api_key_here"" Grafana OnCall uses API keys to allow access to the API. You can request a new OnCall API key in OnCall -> Settings page. -An API key is specific to a user and a Grafana stack. If you want to switch to a different stack configuration, request a different API key. +An API key is specific to a user and a Grafana stack. If you want to switch to a different stack configuration, +request a different API key. ## Pagination @@ -44,7 +45,8 @@ The OnCall API returns them in pages. Note that the page size may vary. ## Rate Limits -Grafana OnCall provides rate limits to ensure alert group notifications will be delivered to your Slack workspace even when some integrations produce a large number of alerts. +Grafana OnCall provides rate limits to ensure alert group notifications will be delivered to your Slack workspace even +when some integrations produce a large number of alerts. ### Monitoring integrations Rate Limits diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index e578644d..abd4e294 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -63,11 +63,16 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/" \ }' ``` + + | Parameter | Required | Description | | --------- | :------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | No | Default setting is `wipe`. `wipe` will remove the payload of all Grafana OnCall group alerts. This is useful if you sent sensitive data to OnCall. All metadata will remain. `DELETE` will trigger the removal of alert groups, alerts, and all related metadata. It will also remove alert group notifications in Slack and other destinations. | -> **NOTE:** `DELETE` can take a few moments to delete alert groups because Grafana OnCall interacts with 3rd party APIs such as Slack. Please check objects using `GET` to be sure the data is removed. + + +> **NOTE:** `DELETE` can take a few moments to delete alert groups because Grafana OnCall interacts with 3rd party APIs +> such as Slack. Please check objects using `GET` to be sure the data is removed. **HTTP request** diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index 26c8a470..0cca0501 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -32,6 +32,8 @@ The above command returns JSON structured in the following way: } ``` + + | Parameter | Required | Description | | ---------------------------------- | :--------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. | @@ -47,6 +49,8 @@ The above command returns JSON structured in the following way: | `notify_if_time_from` | If type = `notify_if_time_from_to` | UTC time represents the beginning of the time period, for example `09:00:00Z`. | | `notify_if_time_to` | If type = `notify_if_time_from_to` | UTC time represents the end of the time period, for example `18:00:00Z`. | + + **HTTP request** `POST {{API_URL}}/api/v1/escalation_policies/` diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index 282e39df..b792e459 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -67,7 +67,8 @@ The above command returns JSON structured in the following way: ``` Integrations are sources of alerts and alert groups for Grafana OnCall. -For example, to learn how to integrate Grafana OnCall with Alertmanager see [Alertmanager]({{< relref "../integrations/available-integrations/configure-alertmanager/" >}}). +For example, to learn how to integrate Grafana OnCall with Alertmanager see +[Alertmanager]({{< relref "../integrations/available-integrations/configure-alertmanager/" >}}). **HTTP request** @@ -278,7 +279,8 @@ The above command returns JSON structured in the following way: # Delete integration -Deleted integrations will stop recording new alerts from monitoring. Integration removal won't trigger removal of related alert groups or alerts. +Deleted integrations will stop recording new alerts from monitoring. Integration removal won't trigger removal of +related alert groups or alerts. ```shell curl "{{API_URL}}/api/v1/integrations/CFRPV98RPR1U8/" \ diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index f06a8d12..dd108676 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -43,6 +43,8 @@ The above command returns JSON structured in the following way: } ``` + + | Parameter | Unique | Required | Description | | -------------------------------- | :----: | :--------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | Yes | On-call shift name. | @@ -52,9 +54,9 @@ The above command returns JSON structured in the following way: | `level` | No | Optional | Priority level. The higher the value, the higher the priority. If two events overlap in one schedule, Grafana OnCall will choose the event with higher level. For example: Alex is on-call from 8AM till 11AM with level 1, Bob is on-call from 9AM till 11AM with level 2. At 10AM Grafana OnCall will notify Bob. At 8AM OnCall will notify Alex. | | `start` | No | Yes | Start time of the on-call shift. This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). | | `duration` | No | Yes | Duration of the event. | -| `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `hourly`, `daily`, `weekly`, `monthly`. | +| `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `hourly`, `daily`, `weekly`, `monthly`. | | `interval` | No | Optional | This parameter takes a positive integer that represents the intervals that the recurrence rule repeats. | -| `until` | No | Optional | When the recurrence rule ends (endless if None). This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). | +| `until` | No | Optional | When the recurrence rule ends (endless if None). This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). | | `week_start` | No | Optional | Start day of the week in iCal format. One of: `SU` (Sunday), `MO` (Monday), `TU` (Tuesday), `WE` (Wednesday), `TH` (Thursday), `FR` (Friday), `SA` (Saturday). Default: `SU`. | | `by_day` | No | Optional | List of days in iCal format. Valid values are: `SU`, `MO`, `TU`, `WE`, `TH`, `FR`, `SA`. | | `by_month` | No | Optional | List of months. Valid values are `1` to `12`. | @@ -63,6 +65,8 @@ The above command returns JSON structured in the following way: | `rolling_users` | No | Optional | List of lists with on-call users (for `rolling_users` event type). Grafana OnCall will iterate over lists of users for every time frame specified in `frequency`. For example: there are two lists of users in `rolling_users` : [[Alex, Bob], [Alice]] and `frequency` = `daily` . This means that the first day Alex and Bob will be notified. The next day: Alice. The day after: Alex and Bob again and so on. | | `start_rotation_from_user_index` | No | Optional | Index of the list of users in `rolling_users`, from which on-call rotation starts. By default, the start index is `0` | + + Please see [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.10) for more information about recurrence rules. **HTTP request** diff --git a/docs/sources/oncall-api-reference/personal_notification_rules.md b/docs/sources/oncall-api-reference/personal_notification_rules.md index fdf92646..cff10373 100644 --- a/docs/sources/oncall-api-reference/personal_notification_rules.md +++ b/docs/sources/oncall-api-reference/personal_notification_rules.md @@ -31,6 +31,8 @@ The above command returns JSON structured in the following way: } ``` + + | Parameter | Required | Description | | ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `user_id` | Yes | User ID | @@ -39,6 +41,8 @@ The above command returns JSON structured in the following way: | `duration` | Optional | A time in secs when type `wait` is chosen for `type`. | | `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. | + + **HTTP request** `POST {{API_URL}}/api/v1/personal_notification_rules/` diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index 5f92e79a..a51209ab 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -46,14 +46,18 @@ Routes allow you to direct different alerts to different messenger channels and - Alerts for different engineering groups - Snoozing spam & debugging alerts + + | Parameter | Unique | Required | Description | | --------------------- | :----: | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `integration_id` | No | Yes | Each route is assigned to a specific integration. | | `escalation_chain_id` | No | Yes | Each route is assigned a specific escalation chain. | -| `routing_regex` | Yes | Yes | Python Regex query (use https://regex101.com/ for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | +| `routing_regex` | Yes | Yes | Python Regex query (use for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | | `position` | Yes | Optional | Route matching is performed one after another starting from position=`0`. Position=`-1` will put the route to the end of the list before `is_the_last_route`. A new route created with a position of an existing route will move the old route (and all following routes) down in the list. | | `slack` | Yes | Optional | Dictionary with Slack-specific settings for a route. | + + **HTTP request** `POST {{API_URL}}/api/v1/routes/` diff --git a/docs/sources/oncall-api-reference/schedules.md b/docs/sources/oncall-api-reference/schedules.md index 53b19f81..4be429b7 100644 --- a/docs/sources/oncall-api-reference/schedules.md +++ b/docs/sources/oncall-api-reference/schedules.md @@ -41,6 +41,8 @@ The above command returns JSON structured in the following way: } ``` + + | Parameter | Unique | Required | Description | | -------------------- | :----: | :--------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | Yes | Schedule name. | @@ -52,6 +54,8 @@ The above command returns JSON structured in the following way: | `slack` | No | Optional | Dictionary with Slack-specific settings for a schedule. Includes `channel_id` and `user_group_id` fields, that take a channel ID and a user group ID from Slack. | | `shifts` | No | Optional | List of shifts. Used for manually added on-call shifts in Schedules with type `calendar`. | + + **HTTP request** `POST {{API_URL}}/api/v1/schedules/` diff --git a/docs/sources/oncall-api-reference/user_groups.md b/docs/sources/oncall-api-reference/user_groups.md index c078eb13..2c5966a0 100644 --- a/docs/sources/oncall-api-reference/user_groups.md +++ b/docs/sources/oncall-api-reference/user_groups.md @@ -38,12 +38,16 @@ The above command returns JSON structured in the following way: } ``` + + | Parameter | Unique | Description | | --------- | :----: | :---------------------------------------------------------------------------------------------------- | | `id` | Yes | User Group ID | | `type` | No | [Slack-defined user groups](https://slack.com/intl/en-ru/help/articles/212906697-Create-a-user-group) | | `slack` | No | Metadata retrieved from Slack. | + + **HTTP request** `GET {{API_URL}}/api/v1/user_groups/` diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index d92eeec5..fbce4292 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -9,7 +9,9 @@ weight: 300 # Grafana OnCall open source guide -Grafana OnCall is a developer-friendly incident response tool that's available to Grafana open source and Grafana Cloud users. The OSS version of Grafana OnCall provides the same reliable on-call management solution along with the flexibility of a self-managed environment. +Grafana OnCall is a developer-friendly incident response tool that's available to Grafana open source and Grafana Cloud +users. The OSS version of Grafana OnCall provides the same reliable on-call management solution along with the +flexibility of a self-managed environment. This guide describes the necessary installation and configuration steps needed to configure OSS Grafana OnCall. @@ -22,27 +24,33 @@ There are three Grafana OnCall OSS environments available: - **Production** environment for reliable cloud installation using Helm: [Production Environment](#production-environment) ## Production Environment -We suggest using our official helm chart for the reliable production deployment of Grafana OnCall. It will deploy Grafana OnCall engine and celery workers, along with RabbitMQ cluster, Redis Cluster, and the database. ->**Note:** The Grafana OnCall engine currently supports one instance of the Grafana OnCall plugin at a time. +We suggest using our official helm chart for the reliable production deployment of Grafana OnCall. It will deploy +Grafana OnCall engine and celery workers, along with RabbitMQ cluster, Redis Cluster, and the database. -Check the [helm chart](https://github.com/grafana/oncall/tree/dev/helm/oncall) for more details. +> **Note:** The Grafana OnCall engine currently supports one instance of the Grafana OnCall plugin at a time. -We'll always be happy to provide assistance with production deployment in [our communities](https://github.com/grafana/oncall#join-community)! +Check the [helm chart](https://github.com/grafana/oncall/tree/dev/helm/oncall) for more details. + +We'll always be happy to provide assistance with production deployment in [our communities](https://github.com/grafana/oncall#join-community)! ## Update Grafana OnCall OSS + To update an OSS installation of Grafana OnCall, please see the update docs: + - **Hobby** playground environment: [README.md](https://github.com/grafana/oncall#update-version) - **Production** Helm environment: [Helm update](https://github.com/grafana/oncall/tree/dev/helm/oncall#update) ## Slack Setup -The Slack integration for Grafana OnCall leverages Slack API features to provide a customizable and useful integration. Refer to the following steps to configure the Slack integration: +The Slack integration for Grafana OnCall leverages Slack API features to provide a customizable and useful integration. +Refer to the following steps to configure the Slack integration: 1. Ensure your Grafana OnCall environment is up and running. -1. Grafana OnCall must be accessible through HTTPS. For development purposes, use [localtunnel](https://github.com/localtunnel/localtunnel). For production purposes, consider establishing a proper web server with HTTPS termination. -For localtunnel, refer to the following configuration: +1. Grafana OnCall must be accessible through HTTPS. For development purposes, use [localtunnel](https://github.com/localtunnel/localtunnel). + For production purposes, consider establishing a proper web server with HTTPS termination. + For localtunnel, refer to the following configuration: ```bash # Choose the unique prefix instead of pretty-turkey-83 @@ -55,107 +63,109 @@ lt --port 8080 -s pretty-turkey-83 --print-requests 1. [Create a Slack Workspace](https://slack.com/create) for development, or use your company workspace. -1. Go to https://api.slack.com/apps and click **Create an App** . +1. Go to and click **Create an App** . 1. Select `From an app manifest` option and select your workspace. -1. Replace the text with the following YAML code block . Be sure to replace `` and `` fields with the appropriate information. +1. Replace the text with the following YAML code block . Be sure to replace `` and `` + fields with the appropriate information. - ```yaml - _metadata: - major_version: 1 - minor_version: 1 - display_information: - name: - features: - app_home: - home_tab_enabled: true - messages_tab_enabled: true - messages_tab_read_only_enabled: false - bot_user: - display_name: - always_online: true - shortcuts: - - name: Create a new incident - type: message - callback_id: incident_create - description: Creates a new OnCall incident - - name: Add to resolution note - type: message - callback_id: add_resolution_note - description: Add this message to resolution note - slash_commands: - - command: /oncall - url: /slack/interactive_api_endpoint/ - description: oncall - should_escape: false - oauth_config: - redirect_urls: - - /api/internal/v1/complete/slack-install-free/ - - /api/internal/v1/complete/slack-login/ - scopes: - user: - - channels:read - - chat:write - - identify - - users.profile:read - bot: - - app_mentions:read - - channels:history - - channels:read - - chat:write - - chat:write.customize - - chat:write.public - - commands - - files:write - - groups:history - - groups:read - - im:history - - im:read - - im:write - - mpim:history - - mpim:read - - mpim:write - - reactions:write - - team:read - - usergroups:read - - usergroups:write - - users.profile:read - - users:read - - users:read.email - - users:write - settings: - event_subscriptions: - request_url: /slack/event_api_endpoint/ - bot_events: - - app_home_opened - - app_mention - - channel_archive - - channel_created - - channel_deleted - - channel_rename - - channel_unarchive - - member_joined_channel - - message.channels - - message.im - - subteam_created - - subteam_members_changed - - subteam_updated - - user_change - interactivity: - is_enabled: true - request_url: /slack/interactive_api_endpoint/ - org_deploy_enabled: false - socket_mode_enabled: false - ``` +```yaml +_metadata: + major_version: 1 + minor_version: 1 +display_information: + name: +features: + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: + always_online: true + shortcuts: + - name: Create a new incident + type: message + callback_id: incident_create + description: Creates a new OnCall incident + - name: Add to resolution note + type: message + callback_id: add_resolution_note + description: Add this message to resolution note + slash_commands: + - command: /oncall + url: /slack/interactive_api_endpoint/ + description: oncall + should_escape: false +oauth_config: + redirect_urls: + - /api/internal/v1/complete/slack-install-free/ + - /api/internal/v1/complete/slack-login/ + scopes: + user: + - channels:read + - chat:write + - identify + - users.profile:read + bot: + - app_mentions:read + - channels:history + - channels:read + - chat:write + - chat:write.customize + - chat:write.public + - commands + - files:write + - groups:history + - groups:read + - im:history + - im:read + - im:write + - mpim:history + - mpim:read + - mpim:write + - reactions:write + - team:read + - usergroups:read + - usergroups:write + - users.profile:read + - users:read + - users:read.email + - users:write +settings: + event_subscriptions: + request_url: /slack/event_api_endpoint/ + bot_events: + - app_home_opened + - app_mention + - channel_archive + - channel_created + - channel_deleted + - channel_rename + - channel_unarchive + - member_joined_channel + - message.channels + - message.im + - subteam_created + - subteam_members_changed + - subteam_updated + - user_change + interactivity: + is_enabled: true + request_url: /slack/interactive_api_endpoint/ + org_deploy_enabled: false + socket_mode_enabled: false +``` 1. Set environment variables by navigating to your Grafana OnCall, then click **Env Variables** and set the following: - ``` - SLACK_CLIENT_OAUTH_ID = Basic Information -> App Credentials -> Client ID - SLACK_CLIENT_OAUTH_SECRET = Basic Information -> App Credentials -> Client Secret - SLACK_SIGNING_SECRET = Basic Information -> App Credentials -> Signing Secret - SLACK_INSTALL_RETURN_REDIRECT_HOST = << OnCall external URL >> - ``` + + ```text + SLACK_CLIENT_OAUTH_ID = Basic Information -> App Credentials -> Client ID + SLACK_CLIENT_OAUTH_SECRET = Basic Information -> App Credentials -> Client Secret + SLACK_SIGNING_SECRET = Basic Information -> App Credentials -> Signing Secret + SLACK_INSTALL_RETURN_REDIRECT_HOST = << OnCall external URL >> + ``` 1. In OnCall, navigate to **ChatOps**, select Slack and click **Install Slack integration**. @@ -163,17 +173,25 @@ lt --port 8080 -s pretty-turkey-83 --print-requests ## Telegram Setup -The Telegram integration for Grafana OnCall is designed for collaborative team work and improved incident response. Refer to the following steps to configure the Telegram integration: +The Telegram integration for Grafana OnCall is designed for collaborative team work and improved incident response. +Refer to the following steps to configure the Telegram integration: -1. Ensure your Grafana OnCall environment is up and running. -2. Create a Telegram bot using [BotFather](https://t.me/BotFather) and save the token provided by BotFather. Please make sure to disable **Group Privacy** for the bot (Bot Settings -> Group Privacy -> Turn off). -3. Paste the token provided by BotFather to the `TELEGRAM_TOKEN` variable on the **Env Variables** page of your Grafana OnCall instance. -4. Set the `TELEGRAM_WEBHOOK_HOST` variable to the external address of your Grafana OnCall instance. Please note that `TELEGRAM_WEBHOOK_HOST` must start with `https://` and be publicly available (meaning that it can be reached by Telegram servers). If your host is private or local, consider using a reverse proxy (e.g. [ngrok](https://ngrok.com)). -5. Now you can connect Telegram accounts on the **Users** page and receive alert groups to Telegram direct messages. Alternatively, in case you want to connect Telegram channels to your Grafana OnCall environment, navigate to the **ChatOps** tab. +1. Ensure your Grafana OnCall environment is up and running. +2. Create a Telegram bot using [BotFather](https://t.me/BotFather) and save the token provided by BotFather. Please make + sure to disable **Group Privacy** for the bot (Bot Settings -> Group Privacy -> Turn off). +3. Paste the token provided by BotFather to the `TELEGRAM_TOKEN` variable on the **Env Variables** page of your + Grafana OnCall instance. +4. Set the `TELEGRAM_WEBHOOK_HOST` variable to the external address of your Grafana OnCall instance. Please note + that `TELEGRAM_WEBHOOK_HOST` must start with `https://` and be publicly available (meaning that it can be reached by + Telegram servers). If your host is private or local, consider using a reverse proxy (e.g. [ngrok](https://ngrok.com)). +5. Now you can connect Telegram accounts on the **Users** page and receive alert groups to Telegram direct messages. + Alternatively, in case you want to connect Telegram channels to your Grafana OnCall environment, navigate + to the **ChatOps** tab. ## Grafana OSS-Cloud Setup The benefits of connecting to Grafana Cloud include: + - Cloud OnCall could monitor OSS OnCall uptime using heartbeat - SMS for user notifications - Phone calls for user notifications. @@ -182,13 +200,16 @@ To connect to Grafana Cloud, refer to the **Cloud** page in your OSS Grafana OnC ## Twilio Setup -Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call notifications using Twilio, complete the following steps: +Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call +notifications using Twilio, complete the following steps: 1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled. -1. From your **OnCall** environment, select **Env Variables** and configure all variables starting with `TWILIO_`. +1. From your **OnCall** environment, select **Env Variables** and configure all variables starting with `TWILIO_`. ## Email Setup -Grafana OnCall is capable of sending emails using SMTP as a user notification step. To setup email notifications, populate the following env variables with your SMTP server credentials: + +Grafana OnCall is capable of sending emails using SMTP as a user notification step. To setup email notifications, populate +the following env variables with your SMTP server credentials: - `EMAIL_HOST` - SMTP server host - `EMAIL_HOST_USER` - SMTP server user diff --git a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py index b3364810..9b3ad515 100644 --- a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py @@ -51,7 +51,7 @@ class AlertGroupTelegramRenderer(AlertGroupBaseRenderer): status_verbose = self.alert_group.get_acknowledge_text() # First line in the invisible link with id of organization. # It is needed to add info about organization to the telegram message for the oncall-gateway. - text = f"" + text = f"" text += f"{status_emoji} #{self.alert_group.inside_organization_number}, {title}\n" text += f"{status_verbose}, alerts: {alerts_count_str}\n" text += f"Source: {self.alert_group.channel.short_name}\n" diff --git a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py index 052313c4..176e82b3 100644 --- a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from django.conf import settings + from apps.base.messaging import get_messaging_backend_from_id from apps.slack.slack_formatter import SlackFormatter from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class TemplateLoader: @@ -172,9 +175,15 @@ class AlertTemplater(ABC): "amixr_incident_id": self.incident_id, # TODO: decide on variable names "amixr_link": self.link, # TODO: decide on variable names } - templated_attr, success = apply_jinja_template(attr_template, data, **context) - if success: - return templated_attr + try: + if attr == "title": + return apply_jinja_template( + attr_template, data, result_length_limit=settings.JINJA_RESULT_TITLE_MAX_LENGTH, **context + ) + else: + return apply_jinja_template(attr_template, data, **context) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return e.fallback_message return None diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index b3355b51..9a6c383e 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -189,12 +189,12 @@ class Alert(models.Model): # set web_title_cache to web title to allow alert group searching based on web_title_cache web_title_template = template_manager.get_attr_template("title", alert_receive_channel, render_for="web") if web_title_template: - web_title_cache = apply_jinja_template(web_title_template, raw_request_data)[0] or None + web_title_cache = apply_jinja_template(web_title_template, raw_request_data) else: web_title_cache = None if grouping_id_template is not None: - group_distinction, _ = apply_jinja_template(grouping_id_template, raw_request_data) + group_distinction = apply_jinja_template(grouping_id_template, raw_request_data) # Insert random uuid to prevent grouping of demo alerts or alerts with group_distinction=None if is_demo or not group_distinction: @@ -204,13 +204,13 @@ class Alert(models.Model): group_distinction = hashlib.md5(str(group_distinction).encode()).hexdigest() if resolve_condition_template is not None: - is_resolve_signal, _ = apply_jinja_template(resolve_condition_template, payload=raw_request_data) + is_resolve_signal = apply_jinja_template(resolve_condition_template, payload=raw_request_data) if isinstance(is_resolve_signal, str): is_resolve_signal = is_resolve_signal.strip().lower() in ["1", "true", "ok"] else: is_resolve_signal = False if acknowledge_condition_template is not None: - is_acknowledge_signal, _ = apply_jinja_template(acknowledge_condition_template, payload=raw_request_data) + is_acknowledge_signal = apply_jinja_template(acknowledge_condition_template, payload=raw_request_data) if isinstance(is_acknowledge_signal, str): is_acknowledge_signal = is_acknowledge_signal.strip().lower() in ["1", "true", "ok"] else: diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index cb08e8e1..2b7659c5 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -1,7 +1,6 @@ import logging -import typing from collections import namedtuple -from typing import Optional +from typing import Optional, TypedDict from urllib.parse import urljoin from uuid import uuid1 @@ -46,8 +45,10 @@ def generate_public_primary_key_for_alert_group(): return new_public_primary_key -class Permalinks(typing.TypedDict): - slack: str +class Permalinks(TypedDict): + slack: Optional[str] + telegram: Optional[str] + web: str class AlertGroupQuerySet(models.QuerySet): @@ -401,12 +402,12 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. raise NotImplementedError @property - def slack_permalink(self): + def slack_permalink(self) -> Optional[str]: if self.slack_message is not None: return self.slack_message.permalink @property - def telegram_permalink(self) -> typing.Optional[str]: + def telegram_permalink(self) -> Optional[str]: """ This property will attempt to access an attribute, `prefetched_telegram_messages`, representing a list of prefetched telegram messages. If this attribute does not exist, it falls back to performing a query. @@ -429,10 +430,11 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. return { "slack": self.slack_permalink, "telegram": self.telegram_permalink, + "web": self.web_link, } @property - def web_link(self): + def web_link(self) -> str: return urljoin(self.channel.organization.web_link, f"?page=incident&id={self.public_primary_key}") @property diff --git a/engine/apps/alerts/models/custom_button.py b/engine/apps/alerts/models/custom_button.py index 4b83adbc..9cbee4a5 100644 --- a/engine/apps/alerts/models/custom_button.py +++ b/engine/apps/alerts/models/custom_button.py @@ -1,6 +1,7 @@ import json import logging import re +from json import JSONDecodeError from django.conf import settings from django.core.validators import MinLengthValidator @@ -9,7 +10,8 @@ from django.db.models import F from django.utils import timezone from requests.auth import HTTPBasicAuth -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) @@ -103,13 +105,19 @@ class CustomButton(models.Model): if self.forward_whole_payload: post_kwargs["json"] = alert.raw_request_data elif self.data: - rendered_data = jinja_template_env.from_string(self.data).render( - { - "alert_payload": self._escape_alert_payload(alert.raw_request_data), - "alert_group_id": alert.group.public_primary_key, - } - ) - post_kwargs["json"] = json.loads(rendered_data) + try: + rendered_data = apply_jinja_template( + self.data, + alert_payload=self._escape_alert_payload(alert.raw_request_data), + alert_group_id=alert.group.public_primary_key, + ) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + post_kwargs["json"] = {"error": e.fallback_message} + + try: + post_kwargs["json"] = json.loads(rendered_data) + except JSONDecodeError: + post_kwargs["data"] = rendered_data return post_kwargs def _escape_alert_payload(self, payload: dict): diff --git a/engine/apps/alerts/tasks/alert_group_web_title_cache.py b/engine/apps/alerts/tasks/alert_group_web_title_cache.py index 5afc6234..963965f4 100644 --- a/engine/apps/alerts/tasks/alert_group_web_title_cache.py +++ b/engine/apps/alerts/tasks/alert_group_web_title_cache.py @@ -76,7 +76,7 @@ def update_web_title_cache(alert_receive_channel_pk, alert_group_pks): if web_title_template: if alert_group.pk in first_alert_map: raw_request_data = first_alert_map[alert_group.pk]["raw_request_data"] - web_title_cache = apply_jinja_template(web_title_template, raw_request_data)[0] or None + web_title_cache = apply_jinja_template(web_title_template, raw_request_data) else: web_title_cache = None else: diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index b841aa3f..3fbca1af 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -53,13 +53,13 @@ def notify_user_task( organization = alert_group.channel.organization if not user.is_notification_allowed: - task_logger.info(f"notify_user_task: user {user.pk} notification is not allowed for role {user.role}") + task_logger.info(f"notify_user_task: user {user.pk} notification is not allowed") UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - reason=f"notification is not allowed for user with role {user.role}", + reason=f"notification is not allowed for user", alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN, ).save() return @@ -252,9 +252,9 @@ def perform_notification(log_record_pk): UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - reason=f"notification is not allowed for user with role {user.role}", + reason=f"notification is not allowed for user", alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN, ).save() return diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 16b3daf3..272ef0b1 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -4,7 +4,6 @@ from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertG from apps.alerts.models import AlertGroup from apps.alerts.tasks.delete_alert_group import delete_alert_group from apps.slack.models import SlackMessage -from common.constants.role import Role @pytest.mark.django_db @@ -14,7 +13,7 @@ def test_render_for_phone_call( make_alert_group, make_alert, ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + organization, _ = make_organization_with_slack_team_identity() alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") alert_group = make_alert_group(alert_receive_channel) @@ -59,7 +58,7 @@ def test_delete( organization, slack_team_identity = make_organization_with_slack_team_identity() slack_channel = make_slack_channel(slack_team_identity, name="general", slack_id="CWER1ASD") - user = make_user(organization=organization, role=Role.ADMIN) + user = make_user(organization=organization) alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index 6a4e1630..e84050b2 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -8,9 +8,9 @@ from apps.alerts.escalation_snapshot.serializers.escalation_policy_snapshot impo from apps.alerts.escalation_snapshot.snapshot_classes import EscalationPolicySnapshot from apps.alerts.escalation_snapshot.utils import eta_for_escalation_step_notify_if_time from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import list_users_to_notify_from_ical from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar -from common.constants.role import Role def get_escalation_policy_snapshot_from_model(escalation_policy): @@ -213,8 +213,8 @@ def test_escalation_step_notify_on_call_schedule_viewer_user( make_schedule, make_on_call_shift, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup - viewer = make_user_for_organization(organization=organization, role=Role.VIEWER) + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup + viewer = make_user_for_organization(organization=organization, role=LegacyAccessControlRole.VIEWER) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) # create on_call_shift with user to notify @@ -263,7 +263,7 @@ def test_escalation_step_notify_user_group( make_slack_user_group, make_escalation_policy, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup slack_team_identity = make_slack_team_identity() organization.slack_team_identity = slack_team_identity organization.save() @@ -295,7 +295,7 @@ def test_escalation_step_notify_if_time( escalation_step_test_setup, make_escalation_policy, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + _, _, _, channel_filter, alert_group, reason = escalation_step_test_setup # current time is not between from_time and to_time, step returns eta now = timezone.now() @@ -358,7 +358,7 @@ def test_escalation_step_notify_if_time( def test_escalation_step_notify_if_num_alerts_in_window( mocked_execute_tasks, escalation_step_test_setup, make_escalation_policy, make_alert ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + _, _, _, channel_filter, alert_group, reason = escalation_step_test_setup make_alert(alert_group=alert_group, raw_request_data={}) make_alert(alert_group=alert_group, raw_request_data={}) @@ -419,7 +419,7 @@ def test_escalation_step_trigger_custom_button( make_custom_action, make_escalation_policy, ): - organization, _, alert_receive_channel, channel_filter, alert_group, reason = escalation_step_test_setup + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup custom_button = make_custom_action(organization=organization) diff --git a/engine/apps/alerts/tests/test_notify_user.py b/engine/apps/alerts/tests/test_notify_user.py index 0f43305b..e6cffe1c 100644 --- a/engine/apps/alerts/tests/test_notify_user.py +++ b/engine/apps/alerts/tests/test_notify_user.py @@ -3,9 +3,11 @@ from unittest.mock import patch import pytest from apps.alerts.tasks.notify_user import notify_user_task, perform_notification +from apps.api.permissions import LegacyAccessControlRole from apps.base.models.user_notification_policy import UserNotificationPolicy from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord -from common.constants.role import Role + +NOTIFICATION_UNAUTHORIZED_MSG = "notification is not allowed for user" @pytest.mark.django_db @@ -131,7 +133,9 @@ def test_notify_user_perform_notification_error_if_viewer( make_user_notification_policy_log_record, ): organization = make_organization() - user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + user_1 = make_user( + organization=organization, role=LegacyAccessControlRole.VIEWER, _verified_phone_number="1234567890" + ) user_notification_policy = make_user_notification_policy( user=user_1, step=UserNotificationPolicy.Step.NOTIFY, @@ -150,11 +154,8 @@ def test_notify_user_perform_notification_error_if_viewer( error_log_record = UserNotificationPolicyLogRecord.objects.last() assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED - assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" - assert ( - error_log_record.notification_error_code - == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ) + assert error_log_record.reason == NOTIFICATION_UNAUTHORIZED_MSG + assert error_log_record.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN @pytest.mark.django_db @@ -165,7 +166,9 @@ def test_notify_user_error_if_viewer( make_alert_group, ): organization = make_organization() - user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + user_1 = make_user( + organization=organization, role=LegacyAccessControlRole.VIEWER, _verified_phone_number="1234567890" + ) alert_receive_channel = make_alert_receive_channel(organization=organization) alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) @@ -173,8 +176,5 @@ def test_notify_user_error_if_viewer( error_log_record = UserNotificationPolicyLogRecord.objects.last() assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED - assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" - assert ( - error_log_record.notification_error_code - == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ) + assert error_log_record.reason == NOTIFICATION_UNAUTHORIZED_MSG + assert error_log_record.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN diff --git a/engine/apps/api/permissions/__init__.py b/engine/apps/api/permissions/__init__.py index d3f1d47e..1b9409c5 100644 --- a/engine/apps/api/permissions/__init__.py +++ b/engine/apps/api/permissions/__init__.py @@ -1,5 +1,294 @@ -from .actions import ActionPermission # noqa: F401 -from .constants import ALL_BASE_ACTIONS, MODIFY_ACTIONS, READ_ACTIONS # noqa: F401 -from .methods import MethodPermission # noqa: F401 -from .owner import IsOwner, IsOwnerOrAdmin, IsOwnerOrAdminOrEditor # noqa: F401 -from .roles import AnyRole, IsAdmin, IsAdminOrEditor, IsEditor, IsStaff, IsViewer # noqa: F401 +import enum +import typing + +from rest_framework import permissions +from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from rest_framework.request import Request +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSet, ViewSetMixin + +from common.utils import getattrd + +ACTION_PREFIX = "grafana-oncall-app" +RBAC_PERMISSIONS_ATTR = "rbac_permissions" +RBAC_OBJECT_PERMISSIONS_ATTR = "rbac_object_permissions" + +ViewSetOrAPIView = typing.Union[ViewSet, APIView] + + +class GrafanaAPIPermission(typing.TypedDict): + action: str + + +class Resources(enum.Enum): + ALERT_GROUPS = "alert-groups" + INTEGRATIONS = "integrations" + ESCALATION_CHAINS = "escalation-chains" + SCHEDULES = "schedules" + CHATOPS = "chatops" + OUTGOING_WEBHOOKS = "outgoing-webhooks" + MAINTENANCE = "maintenance" + API_KEYS = "api-keys" + NOTIFICATIONS = "notifications" + + NOTIFICATION_SETTINGS = "notification-settings" + USER_SETTINGS = "user-settings" + OTHER_SETTINGS = "other-settings" + + +class Actions(enum.Enum): + READ = "read" + WRITE = "write" + ADMIN = "admin" + TEST = "test" + EXPORT = "export" + UPDATE_SETTINGS = "update-settings" + + +class LegacyAccessControlRole(enum.IntEnum): + ADMIN = 0 + EDITOR = 1 + VIEWER = 2 + + @classmethod + def choices(cls): + return tuple((option.value, option.name) for option in cls) + + +class LegacyAccessControlCompatiblePermission: + def __init__(self, resource: Resources, action: Actions, fallback_role: LegacyAccessControlRole) -> None: + self.value = f"{ACTION_PREFIX}.{resource.value}:{action.value}" + self.fallback_role = fallback_role + + +def get_most_authorized_role( + permissions: typing.List[LegacyAccessControlCompatiblePermission], +) -> LegacyAccessControlRole: + if not permissions: + return LegacyAccessControlRole.VIEWER + + # ex. Admin is 0, Viewer is 2, thereby min makes sense here + return min({p.fallback_role for p in permissions}, key=lambda r: r.value) + + +def user_is_authorized(user, required_permissions: typing.List[LegacyAccessControlCompatiblePermission]) -> bool: + if user.organization.is_rbac_permissions_enabled: + user_permissions = [u["action"] for u in user.permissions] + required_permissions = [p.value for p in required_permissions] + return all(permission in user_permissions for permission in required_permissions) + return user.role <= get_most_authorized_role(required_permissions).value + + +class RBACPermission(permissions.BasePermission): + class Permissions: + ALERT_GROUPS_READ = LegacyAccessControlCompatiblePermission( + Resources.ALERT_GROUPS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + ALERT_GROUPS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.ALERT_GROUPS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + INTEGRATIONS_READ = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + INTEGRATIONS_TEST = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.TEST, LegacyAccessControlRole.EDITOR + ) + INTEGRATIONS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + ESCALATION_CHAINS_READ = LegacyAccessControlCompatiblePermission( + Resources.ESCALATION_CHAINS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + ESCALATION_CHAINS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.ESCALATION_CHAINS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + SCHEDULES_READ = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.READ, LegacyAccessControlRole.VIEWER + ) + SCHEDULES_WRITE = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + SCHEDULES_EXPORT = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.EXPORT, LegacyAccessControlRole.EDITOR + ) + + CHATOPS_READ = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + CHATOPS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + CHATOPS_UPDATE_SETTINGS = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.UPDATE_SETTINGS, LegacyAccessControlRole.ADMIN + ) + + OUTGOING_WEBHOOKS_READ = LegacyAccessControlCompatiblePermission( + Resources.OUTGOING_WEBHOOKS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + OUTGOING_WEBHOOKS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.OUTGOING_WEBHOOKS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + MAINTENANCE_READ = LegacyAccessControlCompatiblePermission( + Resources.MAINTENANCE, Actions.READ, LegacyAccessControlRole.VIEWER + ) + MAINTENANCE_WRITE = LegacyAccessControlCompatiblePermission( + Resources.MAINTENANCE, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + API_KEYS_READ = LegacyAccessControlCompatiblePermission( + Resources.API_KEYS, Actions.READ, LegacyAccessControlRole.ADMIN + ) + API_KEYS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.API_KEYS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + NOTIFICATIONS_READ = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATIONS, Actions.READ, LegacyAccessControlRole.EDITOR + ) + + NOTIFICATION_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATION_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + NOTIFICATION_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATION_SETTINGS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + USER_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + USER_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + USER_SETTINGS_ADMIN = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.ADMIN, LegacyAccessControlRole.ADMIN + ) + + OTHER_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.OTHER_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + OTHER_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.OTHER_SETTINGS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + @staticmethod + def _get_view_action(request: Request, view: ViewSetOrAPIView) -> str: + """ + For right now this needs to support being used in both a ViewSet as well as APIView, we use both interchangably + + Note: `request.method` is returned uppercase + """ + return view.action if isinstance(view, ViewSetMixin) else request.method.lower() + + def has_permission(self, request: Request, view: ViewSetOrAPIView) -> bool: + action = self._get_view_action(request, view) + + rbac_permissions: RBACPermissionsAttribute = getattr(view, RBAC_PERMISSIONS_ATTR, None) + + # first check that the rbac_permissions dict attribute is defined + assert ( + rbac_permissions is not None + ), f"Must define a {RBAC_PERMISSIONS_ATTR} dict on the ViewSet that is consuming the RBACPermission class" + + action_required_permissions: typing.Union[None, typing.List] = rbac_permissions.get(action, None) + + # next check that the action in question is defined within the rbac_permissions dict attribute + assert ( + action_required_permissions is not None + ), f"""Each action must be defined within the {RBAC_PERMISSIONS_ATTR} dict on the ViewSet. +\nIf an action requires no permissions, its value should explicitly be set to an empty list""" + + return user_is_authorized(request.user, action_required_permissions) + + def has_object_permission(self, request: Request, view: ViewSetOrAPIView, obj: typing.Any) -> bool: + rbac_object_permissions: RBACObjectPermissionsAttribute = getattr(view, RBAC_OBJECT_PERMISSIONS_ATTR, None) + + if rbac_object_permissions: + action = self._get_view_action(request, view) + + for permission_class, actions in rbac_object_permissions.items(): + if action in actions: + return permission_class.has_object_permission(request, view, obj) + return False + + # has_object_permission is called after has_permission, so return True if in view there is not + # RBAC_OBJECT_PERMISSIONS_ATTR attr which mean no additional check involving object required + return True + + +class IsOwner(permissions.BasePermission): + def __init__(self, ownership_field: typing.Optional[str] = None) -> None: + self.ownership_field = ownership_field + + def has_object_permission(self, request: Request, _view: ViewSet, obj: typing.Any) -> bool: + owner = obj if self.ownership_field is None else getattrd(obj, self.ownership_field) + return owner == request.user + + +class HasRBACPermissions(permissions.BasePermission): + def __init__(self, required_permissions: typing.List[LegacyAccessControlCompatiblePermission]) -> None: + self.required_permissions = required_permissions + + def has_object_permission(self, request: Request, _view: ViewSetOrAPIView, _obj: typing.Any) -> bool: + return user_is_authorized(request.user, self.required_permissions) + + +class IsOwnerOrHasRBACPermissions(permissions.BasePermission): + def __init__( + self, + required_permissions: typing.List[LegacyAccessControlCompatiblePermission], + ownership_field: typing.Optional[str] = None, + ) -> None: + self.IsOwner = IsOwner(ownership_field) + self.HasRBACPermissions = HasRBACPermissions(required_permissions) + + def has_object_permission(self, request: Request, view: ViewSetOrAPIView, obj: typing.Any) -> bool: + return self.IsOwner.has_object_permission(request, view, obj) or self.HasRBACPermissions.has_object_permission( + request, view, obj + ) + + +class IsStaff(permissions.BasePermission): + STAFF_AUTH_CLASSES = [BasicAuthentication, SessionAuthentication] + + def has_permission(self, request: Request, _view: ViewSet) -> bool: + user = request.user + if not any(isinstance(request._authenticator, x) for x in self.STAFF_AUTH_CLASSES): + return False + if user and user.is_authenticated: + return user.is_staff + return False + + +RBACPermissionsAttribute = typing.Dict[str, typing.List[LegacyAccessControlCompatiblePermission]] +RBACObjectPermissionsAttribute = typing.Dict[permissions.BasePermission, typing.List[str]] + + +# The below is legacy, it is only needed currently for backward compatibility w/ users running +# older "pinned" version of Grafana in Grafana Cloud +_DONT_USE_LEGACY_VIEWER_PERMISSIONS = [] +_DONT_USE_LEGACY_EDITOR_PERMISSIONS = ["update_incidents", "update_own_settings", "view_other_users"] +_DONT_USE_LEGACY_ADMIN_PERMISSIONS = _DONT_USE_LEGACY_EDITOR_PERMISSIONS + [ + "update_alert_receive_channels", + "update_escalation_policies", + "update_notification_policies", + "update_general_log_channel_id", + "update_other_users_settings", + "update_integrations", + "update_schedules", + "update_custom_actions", + "update_api_tokens", + "update_teams", + "update_maintenances", + "update_global_settings", + "send_demo_alert", +] + +DONT_USE_LEGACY_PERMISSION_MAPPING: typing.Dict[LegacyAccessControlRole, typing.List[str]] = { + LegacyAccessControlRole.VIEWER: _DONT_USE_LEGACY_VIEWER_PERMISSIONS, + LegacyAccessControlRole.EDITOR: _DONT_USE_LEGACY_EDITOR_PERMISSIONS, + LegacyAccessControlRole.ADMIN: _DONT_USE_LEGACY_ADMIN_PERMISSIONS, +} diff --git a/engine/apps/api/permissions/actions.py b/engine/apps/api/permissions/actions.py deleted file mode 100644 index 74136e12..00000000 --- a/engine/apps/api/permissions/actions.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - - -class ActionPermission(permissions.BasePermission): - def has_permission(self, request: Request, view: ViewSet) -> bool: - for permission, actions in getattr(view, "action_permissions", {}).items(): - if view.action in actions: - return permission().has_permission(request, view) - - return False - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - # action_object_permissions attr should be used in case permission check require lookup - # for some object's properties e.g. team. - if getattr(view, "action_object_permissions", None): - for permission, actions in getattr(view, "action_object_permissions", {}).items(): - if view.action in actions: - return permission().has_object_permission(request, view, obj) - return False - else: - # has_object_permission is called after has_permission, so return True if in view there is not - # action_object_permission attr which mean no additional check involving object required - return True diff --git a/engine/apps/api/permissions/constants.py b/engine/apps/api/permissions/constants.py deleted file mode 100644 index 29e828ce..00000000 --- a/engine/apps/api/permissions/constants.py +++ /dev/null @@ -1,14 +0,0 @@ -READ_ACTIONS = ( - "list", - "retrieve", - "metadata", -) - -MODIFY_ACTIONS = ( - "create", - "update", - "partial_update", - "destroy", -) - -ALL_BASE_ACTIONS = READ_ACTIONS + MODIFY_ACTIONS diff --git a/engine/apps/api/permissions/methods.py b/engine/apps/api/permissions/methods.py deleted file mode 100644 index 6ff1b110..00000000 --- a/engine/apps/api/permissions/methods.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - - -class MethodPermission(permissions.BasePermission): - def has_permission(self, request: Request, view: ViewSet) -> bool: - for permission, methods in getattr(view, "method_permissions", {}).items(): - if request.method in methods: - return permission().has_permission(request, view) - - return False diff --git a/engine/apps/api/permissions/owner.py b/engine/apps/api/permissions/owner.py deleted file mode 100644 index 4a4fc69e..00000000 --- a/engine/apps/api/permissions/owner.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - -from apps.api.permissions.roles import IsAdmin, IsEditor -from common.utils import getattrd - - -class IsOwner(permissions.BasePermission): - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - ownership_field = getattr(view, "ownership_field", None) - if ownership_field is None: - owner = obj - else: - owner = getattrd(obj, ownership_field) - - return owner == request.user - - -IsOwnerOrAdmin = IsOwner | IsAdmin - -IsOwnerOrAdminOrEditor = IsOwner | IsAdmin | IsEditor diff --git a/engine/apps/api/permissions/roles.py b/engine/apps/api/permissions/roles.py deleted file mode 100644 index 3ae9d548..00000000 --- a/engine/apps/api/permissions/roles.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.authentication import BasicAuthentication, SessionAuthentication -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - -from common.constants.role import Role - - -class RolePermission(permissions.BasePermission): - ROLE = None - - def has_permission(self, request: Request, view: ViewSet) -> bool: - return request.user.role == type(self).ROLE - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - return self.has_permission(request, view) - - -class IsAdmin(RolePermission): - ROLE = Role.ADMIN - - -class IsEditor(RolePermission): - ROLE = Role.EDITOR - - -class IsViewer(RolePermission): - ROLE = Role.VIEWER - - -IsAdminOrEditor = IsAdmin | IsEditor -AnyRole = IsAdmin | IsEditor | IsViewer - - -class IsStaff(permissions.BasePermission): - STAFF_AUTH_CLASSES = [BasicAuthentication, SessionAuthentication] - - def has_permission(self, request: Request, view: ViewSet) -> bool: - user = request.user - if not any(isinstance(request._authenticator, x) for x in self.STAFF_AUTH_CLASSES): - return False - if user and user.is_authenticated: - return user.is_staff - return False - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - return self.has_permission(request, view) diff --git a/engine/apps/api/permissions/test_permissions.py b/engine/apps/api/permissions/test_permissions.py new file mode 100644 index 00000000..e0d39b10 --- /dev/null +++ b/engine/apps/api/permissions/test_permissions.py @@ -0,0 +1,428 @@ +import typing + +import pytest +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSetMixin + +from . import ( + RBAC_PERMISSIONS_ATTR, + GrafanaAPIPermission, + HasRBACPermissions, + IsOwner, + IsOwnerOrHasRBACPermissions, + LegacyAccessControlCompatiblePermission, + RBACObjectPermissionsAttribute, + RBACPermission, + RBACPermissionsAttribute, + get_most_authorized_role, + user_is_authorized, +) + + +class MockedOrg: + def __init__(self, org_has_rbac_enabled: bool) -> None: + self.is_rbac_permissions_enabled = org_has_rbac_enabled + + +class MockedUser: + def __init__( + self, permissions: typing.List[LegacyAccessControlCompatiblePermission], org_has_rbac_enabled=True + ) -> None: + self.permissions = [GrafanaAPIPermission(action=perm.value) for perm in permissions] + self.role = get_most_authorized_role(permissions) + self.organization = MockedOrg(org_has_rbac_enabled) + + +class MockedSchedule: + def __init__(self, user: MockedUser) -> None: + self.user = user + + +class MockedRequest: + def __init__(self, user: typing.Optional[MockedUser] = None, method: typing.Optional[str] = None) -> None: + if user: + self.user = user + if method: + self.method = method + + +class MockedViewSet(ViewSetMixin): + def __init__( + self, + action: str, + rbac_permissions: typing.Optional[RBACPermissionsAttribute] = None, + rbac_object_permissions: typing.Optional[RBACObjectPermissionsAttribute] = None, + ) -> None: + super().__init__() + self.action = action + + if rbac_permissions: + self.rbac_permissions = rbac_permissions + if rbac_object_permissions: + self.rbac_object_permissions = rbac_object_permissions + + +class MockedAPIView(APIView): + def __init__( + self, + rbac_permissions: typing.Optional[RBACPermissionsAttribute] = None, + rbac_object_permissions: typing.Optional[RBACObjectPermissionsAttribute] = None, + ) -> None: + super().__init__() + + if rbac_permissions: + self.rbac_permissions = rbac_permissions + if rbac_object_permissions: + self.rbac_object_permissions = rbac_object_permissions + + +@pytest.mark.parametrize( + "user_permissions,required_permissions,org_has_rbac_enabled,expected_result", + [ + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + False, + ), + ], +) +def test_user_is_authorized(user_permissions, required_permissions, org_has_rbac_enabled, expected_result) -> None: + user = MockedUser(user_permissions, org_has_rbac_enabled=org_has_rbac_enabled) + assert user_is_authorized(user, required_permissions) == expected_result + + +@pytest.mark.parametrize( + "permissions,expected_role", + [ + ([RBACPermission.Permissions.ALERT_GROUPS_READ], RBACPermission.Permissions.ALERT_GROUPS_READ.fallback_role), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + RBACPermission.Permissions.ALERT_GROUPS_WRITE.fallback_role, + ), + ( + [ + RBACPermission.Permissions.USER_SETTINGS_READ, + RBACPermission.Permissions.USER_SETTINGS_WRITE, + RBACPermission.Permissions.USER_SETTINGS_ADMIN, + ], + RBACPermission.Permissions.USER_SETTINGS_ADMIN.fallback_role, + ), + ], +) +def test_get_most_authorized_role(permissions, expected_role) -> None: + assert get_most_authorized_role(permissions) == expected_role + + +class TestRBACPermission: + def test_get_view_action(self) -> None: + viewset_action = "viewset_action" + viewset = MockedViewSet(viewset_action) + + apiview = MockedAPIView() + + method = "APIVIEW_ACTION" + request = MockedRequest(method=method) + + assert RBACPermission._get_view_action(request, viewset) == viewset_action, "it works with a ViewSet" + assert RBACPermission._get_view_action(request, apiview) == method.lower(), "it works with an APIView" + + def test_has_permission_works_on_a_viewset_view(self) -> None: + required_permission = RBACPermission.Permissions.ALERT_GROUPS_READ + + action = "hello" + viewset = MockedViewSet( + action=action, + rbac_permissions={ + action: [required_permission], + }, + ) + + viewset_with_no_required_permissions = MockedViewSet( + action=action, + rbac_permissions={ + action: [], + }, + ) + + user_with_permission = MockedUser([required_permission]) + user_without_permission = MockedUser([RBACPermission.Permissions.ALERT_GROUPS_WRITE]) + + assert ( + RBACPermission().has_permission(MockedRequest(user_with_permission), viewset) is True + ), "it works on a viewset when the user does have permission" + + assert ( + RBACPermission().has_permission(MockedRequest(user_without_permission), viewset) is False + ), "it works on a viewset when the user does have permission" + + assert ( + RBACPermission().has_permission( + MockedRequest(user_without_permission), viewset_with_no_required_permissions + ) + is True + ), "it works on a viewset when the viewset action does not require permissions" + + def test_has_permission_works_on_an_apiview_view(self) -> None: + required_permission = RBACPermission.Permissions.ALERT_GROUPS_READ + + method = "hello" + apiview = MockedAPIView( + rbac_permissions={ + method: [required_permission], + } + ) + apiview_with_no_permissions = MockedAPIView( + rbac_permissions={ + method: [], + } + ) + + user1 = MockedUser([required_permission]) + user2 = MockedUser([RBACPermission.Permissions.ALERT_GROUPS_WRITE]) + + class Request(MockedRequest): + def __init__(self, user: typing.Optional[MockedUser] = None) -> None: + super().__init__(user, method) + + assert ( + RBACPermission().has_permission(Request(user1), apiview) is True + ), "it works on an APIView when the user has permission" + + assert ( + RBACPermission().has_permission(Request(user2), apiview) is False + ), "it works on an APIView when the user does not have permission" + + assert ( + RBACPermission().has_permission(Request(user2), apiview_with_no_permissions) is True + ), "it works on a viewset when the viewset action does not require permissions" + + def test_has_permission_throws_assertion_error_if_developer_forgets_to_specify_rbac_permissions(self) -> None: + action_slash_method = "hello" + error_msg = ( + f"Must define a {RBAC_PERMISSIONS_ATTR} dict on the ViewSet that is consuming the RBACPermission class" + ) + + viewset = MockedViewSet(action_slash_method) + apiview = MockedAPIView() + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(), viewset) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(method=action_slash_method), apiview) + + def test_has_permission_throws_assertion_error_if_developer_forgets_to_specify_an_action_in_rbac_permissions( + self, + ) -> None: + action_slash_method = "hello" + other_action_rbac_permissions = {"bonjour": []} + error_msg = f"""Each action must be defined within the {RBAC_PERMISSIONS_ATTR} dict on the ViewSet. +\nIf an action requires no permissions, its value should explicitly be set to an empty list""" + + viewset = MockedViewSet(action_slash_method, other_action_rbac_permissions) + apiview = MockedAPIView(rbac_permissions=other_action_rbac_permissions) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(), viewset) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(method=action_slash_method), apiview) + + def test_has_object_permission_returns_true_if_rbac_object_permissions_not_specified(self) -> None: + request = MockedRequest() + assert RBACPermission().has_object_permission(request, MockedAPIView(), None) is True + assert RBACPermission().has_object_permission(request, MockedViewSet("potato"), None) is True + + def test_has_object_permission_works_if_no_permission_class_specified_for_action(self) -> None: + action = "hello" + + request = MockedRequest(None, action) + apiview = MockedAPIView(rbac_object_permissions={}) + viewset = MockedViewSet(action, rbac_object_permissions={}) + + assert RBACPermission().has_object_permission(request, apiview, None) is True + assert RBACPermission().has_object_permission(request, viewset, None) is True + + def test_has_object_permission_works_when_permission_class_specified_for_action(self) -> None: + action = "hello" + mocked_permission_class_response = "asdfasdfasdf" + + class MockedPermissionClass: + def has_object_permission(self, _req, _view, _obj) -> None: + return mocked_permission_class_response + + rbac_object_permissions = {MockedPermissionClass(): (action,)} + request = MockedRequest(None, action) + apiview = MockedAPIView(rbac_object_permissions=rbac_object_permissions) + viewset = MockedViewSet(action, rbac_object_permissions=rbac_object_permissions) + + assert RBACPermission().has_object_permission(request, apiview, None) == mocked_permission_class_response + assert RBACPermission().has_object_permission(request, viewset, None) == mocked_permission_class_response + + +class TestIsOwner: + def test_it_works_when_comparing_user_to_object(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + request = MockedRequest(user1) + IsUser = IsOwner() + + assert IsUser.has_object_permission(request, None, user1) is True + assert IsUser.has_object_permission(request, None, user2) is False + + def test_it_works_when_comparing_user_to_ownership_field_object(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + IsScheduleOwner = IsOwner("user") + + assert IsScheduleOwner.has_object_permission(MockedRequest(user1), None, schedule) is True + assert IsScheduleOwner.has_object_permission(MockedRequest(user2), None, schedule) is False + + def test_it_works_when_comparing_user_to_nested_ownership_field_object(self) -> None: + class Thingy: + def __init__(self, schedule: MockedSchedule) -> None: + self.schedule = schedule + + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + thingy = Thingy(schedule) + IsScheduleOwner = IsOwner("schedule.user") + + assert IsScheduleOwner.has_object_permission(MockedRequest(user1), None, thingy) is True + assert IsScheduleOwner.has_object_permission(MockedRequest(user2), None, thingy) is False + + +@pytest.mark.parametrize( + "user_permissions,required_permissions,expected_result", + [ + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + ), + ], +) +def test_HasRBACPermission(user_permissions, required_permissions, expected_result) -> None: + request = MockedRequest(MockedUser(user_permissions)) + assert HasRBACPermissions(required_permissions).has_object_permission(request, None, None) == expected_result + + +class TestIsOwnerOrHasRBACPermissions: + required_permission = RBACPermission.Permissions.SCHEDULES_READ + required_permissions = [required_permission] + + def test_it_works_when_user_is_owner_and_does_not_have_permissions(self) -> None: + user1 = MockedUser([]) + schedule = MockedSchedule(user1) + request = MockedRequest(user1) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + def test_it_works_when_user_is_owner_and_has_permissions(self) -> None: + user1 = MockedUser(self.required_permissions) + schedule = MockedSchedule(user1) + request = MockedRequest(user1) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + def test_it_works_when_user_is_not_owner_and_does_not_have_permissions(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + request = MockedRequest(user2) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is False + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is False + + def test_it_works_when_user_is_not_owner_and_has_permissions(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser(self.required_permissions) + schedule = MockedSchedule(user1) + request = MockedRequest(user2) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + class Thingy: + def __init__(self, schedule: MockedSchedule) -> None: + self.schedule = schedule + + thingy = Thingy(schedule) + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "schedule.user") + + assert PermClass.has_object_permission(request, None, thingy) is True + assert PermClass.has_object_permission(MockedRequest(MockedUser([])), None, thingy) is False diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index a71cfde2..5c2e9517 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -132,7 +132,8 @@ class AlertGroupSerializer(AlertGroupListSerializer): fields = AlertGroupListSerializer.Meta.fields + [ "alerts", "render_after_resolve_report_json", - "slack_permalink", + "slack_permalink", # TODO: make plugin frontend use "permalinks" field to get Slack link + "permalinks", "last_alert_at", ] diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 91823581..c8df5ad3 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -20,7 +20,8 @@ from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, Writabl from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import IMAGE_URL, TEMPLATE_NAMES_ONLY_WITH_NOTIFICATION_CHANNEL, EagerLoadingMixin from common.api_helpers.utils import CurrentTeamDefault -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template, jinja_template_env +from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning from .integration_heartbeat import IntegrationHeartBeatSerializer @@ -28,9 +29,10 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer def valid_jinja_template_for_serializer_method_field(template): for _, val in template.items(): try: - jinja_template_env.from_string(val) - except TemplateSyntaxError: - raise serializers.ValidationError("invalid template") + apply_jinja_template(val, payload={}) + except JinjaTemplateWarning: + # Suppress warnings, template may be valid with payload + pass class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializer): diff --git a/engine/apps/api/serializers/custom_button.py b/engine/apps/api/serializers/custom_button.py index 86ee2def..d0026b9f 100644 --- a/engine/apps/api/serializers/custom_button.py +++ b/engine/apps/api/serializers/custom_button.py @@ -1,15 +1,14 @@ -import json from collections import defaultdict from django.core.validators import URLValidator, ValidationError -from jinja2 import TemplateError from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from apps.alerts.models import CustomButton from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class CustomButtonSerializer(serializers.ModelSerializer): @@ -53,32 +52,12 @@ class CustomButtonSerializer(serializers.ModelSerializer): return None try: - template = jinja_template_env.from_string(data) - except TemplateError: - raise serializers.ValidationError("Data has incorrect template") - - try: - rendered = template.render( - { - # Validate that the template can be rendered with a JSON-ish alert payload. - # We don't know what the actual payload will be, so we use a defaultdict - # so that attribute access within a template will never fail - # (provided it's only one level deep - we won't accept templates that attempt - # to do nested attribute access). - # Every attribute access should return a string to ensure that users are - # correctly using `tojson` or wrapping fields in strings. - # If we instead used a `defaultdict(dict)` or `defaultdict(lambda: 1)` we - # would accidentally accept templates such as `{"name": {{ alert_payload.name }}}` - # which would then fail at the true render time due to the - # lack of explicit quotes around the template variable; this would render - # as `{"name": some_alert_name}` which is not valid JSON. - "alert_payload": defaultdict(str), - "alert_group_id": "abcd", - } - ) - json.loads(rendered) - except ValueError: - raise serializers.ValidationError("Data has incorrect format") + apply_jinja_template(data, alert_payload=defaultdict(str), alert_group_id="abcd") + except JinjaTemplateError as e: + raise serializers.ValidationError(e.fallback_message) + except JinjaTemplateWarning: + # Suppress render exceptions since we do not have a representative payload to test with + pass return data diff --git a/engine/apps/api/serializers/schedule_calendar.py b/engine/apps/api/serializers/schedule_calendar.py index 6a231962..436685e0 100644 --- a/engine/apps/api/serializers/schedule_calendar.py +++ b/engine/apps/api/serializers/schedule_calendar.py @@ -1,15 +1,14 @@ -from rest_framework import serializers - from apps.api.serializers.schedule_base import ScheduleBaseSerializer from apps.schedules.models import OnCallScheduleCalendar from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule from apps.slack.models import SlackChannel, SlackUserGroup from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.utils import validate_ical_url +from common.timezones import TimeZoneField class ScheduleCalendarSerializer(ScheduleBaseSerializer): - time_zone = serializers.CharField(required=False) + time_zone = TimeZoneField(required=False) class Meta: model = OnCallScheduleCalendar diff --git a/engine/apps/api/serializers/schedule_web.py b/engine/apps/api/serializers/schedule_web.py index fadc8b4b..86414e12 100644 --- a/engine/apps/api/serializers/schedule_web.py +++ b/engine/apps/api/serializers/schedule_web.py @@ -1,14 +1,13 @@ -from rest_framework import serializers - from apps.api.serializers.schedule_base import ScheduleBaseSerializer from apps.schedules.models import OnCallScheduleWeb from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule from apps.slack.models import SlackChannel, SlackUserGroup from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField +from common.timezones import TimeZoneField class ScheduleWebSerializer(ScheduleBaseSerializer): - time_zone = serializers.CharField(required=False) + time_zone = TimeZoneField(required=False) class Meta: model = OnCallScheduleWeb diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 5f428d87..804cdbfa 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,12 +1,12 @@ import math import time +import typing -import pytz from django.conf import settings from rest_framework import serializers +from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING from apps.api.serializers.telegram import TelegramToUserConnectorSerializer -from apps.base.constants import ADMIN_PERMISSIONS, ALL_ROLES_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.messaging import get_messaging_backends from apps.base.models import UserNotificationPolicy from apps.base.utils import live_settings @@ -16,7 +16,7 @@ from apps.user_management.models import User from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin -from common.constants.role import Role +from common.timezones import TimeZoneField from .custom_serializers import DynamicFieldsModelSerializer from .organization import FastOrganizationSerializer @@ -34,9 +34,9 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): organization = FastOrganizationSerializer(read_only=True) current_team = TeamPrimaryKeyRelatedField(allow_null=True, required=False) - timezone = serializers.CharField(allow_null=True, required=False) + timezone = TimeZoneField(allow_null=True, required=False) avatar = serializers.URLField(source="avatar_url", read_only=True) - + avatar_full = serializers.URLField(source="avatar_full_url", read_only=True) permissions = serializers.SerializerMethodField() notification_chain_verbal = serializers.SerializerMethodField() cloud_connection_status = serializers.SerializerMethodField() @@ -51,8 +51,10 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "current_team", "email", "username", - "role", + "name", + "role", # LEGACY.. this should get removed eventually "avatar", + "avatar_full", "timezone", "working_hours", "unverified_phone_number", @@ -60,7 +62,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "slack_user_identity", "telegram_configuration", "messaging_backends", - "permissions", + "permissions", # LEGACY.. this should get removed eventually "notification_chain_verbal", "cloud_connection_status", "hide_phone_number", @@ -68,21 +70,11 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): read_only_fields = [ "email", "username", - "role", + "name", + "role", # LEGACY.. this should get removed eventually "verified_phone_number", ] - def validate_timezone(self, tz): - if tz is None: - return tz - - try: - pytz.timezone(tz) - except pytz.UnknownTimeZoneError: - raise serializers.ValidationError("not a valid timezone") - - return tz - def validate_working_hours(self, working_hours): if not isinstance(working_hours, dict): raise serializers.ValidationError("must be dict") @@ -136,13 +128,8 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): serialized_data[backend_id] = backend.serialize_user(obj) return serialized_data - def get_permissions(self, obj): - if obj.role == Role.ADMIN: - return ADMIN_PERMISSIONS - elif obj.role == Role.EDITOR: - return EDITOR_PERMISSIONS - else: - return ALL_ROLES_PERMISSIONS + def get_permissions(self, obj) -> typing.List[str]: + return DONT_USE_LEGACY_PERMISSION_MAPPING[obj.role] def get_notification_chain_verbal(self, obj): default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) @@ -177,7 +164,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): class UserHiddenFieldsSerializer(UserSerializer): - available_for_all_roles_fields = [ + fields_available_for_all_users = [ "pk", "organization", "current_team", @@ -193,7 +180,7 @@ class UserHiddenFieldsSerializer(UserSerializer): ret = super(UserSerializer, self).to_representation(instance) if instance.id != self.context["request"].user.id: for field in ret: - if field not in self.available_for_all_roles_fields: + if field not in self.fields_available_for_all_users: ret[field] = "******" ret["hidden_fields"] = True return ret diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 4ef75494..ebc2f41c 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import AlertGroup, AlertGroupLogRecord -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole alert_raw_request_data = { "evalMatches": [ @@ -25,15 +25,6 @@ alert_raw_request_data = { } -# # This function is for creating token and do not to change fixture alert_group_internal_api_setup return values. -# # To create token amixr team is needed but in most tests using fixture alert_group_internal_api_setup team is redundant -# # So it just extract amixr team form alert_groups. -# def create_token_from_initial_test_data(make_func, alert_groups, role): -# organization = alert_groups[0].channel.organization -# _, token_user_role = make_func(organization, role) -# return token_user_role - - @pytest.fixture() def alert_group_internal_api_setup( make_organization_and_user_with_plugin_token, @@ -52,7 +43,7 @@ def alert_group_internal_api_setup( @pytest.mark.django_db def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_headers): - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup client = APIClient() url = reverse("api-internal:alertgroup-list") @@ -69,7 +60,7 @@ def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup url = reverse("api-internal:alertgroup-list") response = client.get( @@ -84,7 +75,7 @@ def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api @pytest.mark.django_db def test_get_filter_resolved_at_alertgroup_invalid_format(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup url = reverse("api-internal:alertgroup-list") response = client.get( @@ -660,19 +651,25 @@ def test_get_filter_with_resolution_note_after_delete_resolution_note( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_acknowledge_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() + url = reverse("api-internal:alertgroup-acknowledge", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -689,19 +686,24 @@ def test_alert_group_acknowledge_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unacknowledge_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unacknowledge", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -718,19 +720,24 @@ def test_alert_group_unacknowledge_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_resolve_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-resolve", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -747,19 +754,24 @@ def test_alert_group_resolve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unresolve_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unresolve", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -776,19 +788,24 @@ def test_alert_group_unresolve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_silence_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-silence", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -805,19 +822,24 @@ def test_alert_group_silence_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unsilence_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unsilence", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -834,19 +856,24 @@ def test_alert_group_unsilence_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_attach_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-attach", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -863,19 +890,24 @@ def test_alert_group_attach_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unattach_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unattach", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -892,19 +924,24 @@ def test_alert_group_unattach_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_list_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-list") with patch( @@ -921,19 +958,24 @@ def test_alert_group_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_stats_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-stats") with patch( @@ -950,19 +992,24 @@ def test_alert_group_stats_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_bulk_action_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-bulk-action") with patch( @@ -977,19 +1024,24 @@ def test_alert_group_bulk_action_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_filters_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-filters") with patch( @@ -1006,19 +1058,24 @@ def test_alert_group_filters_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_detail_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-detail", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -1032,10 +1089,7 @@ def test_alert_group_detail_permissions( @pytest.mark.django_db -def test_silence( - alert_group_internal_api_setup, - make_user_auth_headers, -): +def test_silence(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() user, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups @@ -1396,9 +1450,9 @@ def test_alert_group_status_field( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_preview_template_permissions( @@ -1414,6 +1468,7 @@ def test_alert_group_preview_template_permissions( alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + client = APIClient() url = reverse("api-internal:alertgroup-preview-template", kwargs={"pk": alert_group.public_primary_key}) @@ -1436,7 +1491,7 @@ def test_alert_group_preview_body_non_existent_template_var( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -1446,8 +1501,9 @@ def test_alert_group_preview_body_non_existent_template_var( data = {"template_name": "testonly_title_template", "template_body": "foobar: {{ foobar.does_not_exist }}"} response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + # Return errors as preview body instead of None assert response.status_code == status.HTTP_200_OK - assert response.json()["preview"] is None + assert response.json()["preview"] == "Template Warning: 'foobar' is undefined" @pytest.mark.django_db @@ -1458,7 +1514,7 @@ def test_alert_group_preview_body_invalid_template_syntax( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -1468,4 +1524,6 @@ def test_alert_group_preview_body_invalid_template_syntax( data = {"template_name": "testonly_title_template", "template_body": "{{'' if foo is None else foo}}"} response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST + # Errors now returned preview content + assert response.status_code == status.HTTP_200_OK + assert response.data["preview"] == "Template Error: No test named 'None' found." diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index f849cc5f..2ce07341 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel, EscalationPolicy -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -205,9 +205,9 @@ def test_integration_search( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_create_permissions( @@ -235,9 +235,9 @@ def test_alert_receive_channel_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_update_permissions( @@ -272,9 +272,9 @@ def test_alert_receive_channel_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_delete_permissions( @@ -303,7 +303,11 @@ def test_alert_receive_channel_delete_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_alert_receive_channel_list_permissions( make_organization_and_user_with_plugin_token, @@ -311,7 +315,7 @@ def test_alert_receive_channel_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:alert_receive_channel-list") @@ -330,7 +334,11 @@ def test_alert_receive_channel_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_alert_receive_channel_detail_permissions( make_organization_and_user_with_plugin_token, @@ -360,9 +368,9 @@ def test_alert_receive_channel_detail_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_send_demo_alert_permissions( @@ -395,9 +403,9 @@ def test_alert_receive_channel_send_demo_alert_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_integration_options_permissions( @@ -426,9 +434,9 @@ def test_alert_receive_channel_integration_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_preview_template_permissions( @@ -501,9 +509,9 @@ def test_alert_receive_channel_preview_template_require_notification_channel( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_change_team_permissions( @@ -597,9 +605,9 @@ def test_alert_receive_channel_change_team( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_counters_permissions( @@ -608,7 +616,7 @@ def test_alert_receive_channel_counters_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse( @@ -630,9 +638,9 @@ def test_alert_receive_channel_counters_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_counters_per_integration_permissions( diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index 16330810..0c2d658d 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -6,17 +6,17 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.base.messaging import BaseMessagingBackend -from common.constants.role import Role @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_template_update_permissions( @@ -48,9 +48,9 @@ def test_alert_receive_channel_template_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_template_detail_permissions( @@ -83,7 +83,7 @@ def test_alert_receive_channel_template_include_additional_backend_templates( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={"TESTONLY": {"title": "the-title", "message": "the-message", "image_url": "url"}}, @@ -109,7 +109,7 @@ def test_alert_receive_channel_template_include_additional_backend_templates_usi make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -138,7 +138,7 @@ def test_update_alert_receive_channel_backend_template_invalid_template( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -160,7 +160,7 @@ def test_update_alert_receive_channel_backend_template_invalid_url( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -182,7 +182,7 @@ def test_update_alert_receive_channel_backend_template_empty_values_allowed( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -208,7 +208,7 @@ def test_update_alert_receive_channel_backend_template_update_values( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={ @@ -249,7 +249,7 @@ def test_preview_alert_receive_channel_backend_templater( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter) @@ -280,7 +280,7 @@ def test_update_alert_receive_channel_templates( # set url here to pass *_url templates validation return "https://grafana.com" - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={"TESTONLY": {"title": "the-title", "message": "the-message", "image_url": "url"}}, diff --git a/engine/apps/api/tests/test_channel_filter.py b/engine/apps/api/tests/test_channel_filter.py index f70c8956..fe02e97b 100644 --- a/engine/apps/api/tests/test_channel_filter.py +++ b/engine/apps/api/tests/test_channel_filter.py @@ -6,21 +6,20 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_create_permissions( make_organization_and_user_with_plugin_token, - make_alert_receive_channel, make_user_auth_headers, role, expected_status, @@ -45,9 +44,9 @@ def test_channel_filter_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_update_permissions( @@ -83,7 +82,11 @@ def test_channel_filter_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_channel_filter_list_permissions( make_organization_and_user_with_plugin_token, @@ -114,7 +117,11 @@ def test_channel_filter_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_channel_filter_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -146,9 +153,9 @@ def test_channel_filter_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_delete_permissions( @@ -181,9 +188,9 @@ def test_channel_filter_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_move_to_position_permissions( @@ -216,9 +223,9 @@ def test_channel_filter_move_to_position_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_send_demo_alert_permissions( diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py index 2d519d83..3ed57a4f 100644 --- a/engine/apps/api/tests/test_custom_button.py +++ b/engine/apps/api/tests/test_custom_button.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import CustomButton -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole TEST_URL = "https://amixr.io" @@ -236,23 +236,7 @@ def test_create_invalid_data_custom_button(custom_button_internal_api_setup, mak data = { "name": "amixr_button_invalid_data", "webhook": TEST_URL, - "data": "invalid_json", - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_create_invalid_templated_data_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_invalid_data", - "webhook": TEST_URL, - # This would need a `| tojson` or some double quotes around it to pass validation. - "data": "{{ alert_payload.name }}", + "data": "{{%", } response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -291,14 +275,13 @@ def test_delete_custom_button(custom_button_internal_api_setup, make_user_auth_h @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_create_permissions( make_organization_and_user_with_plugin_token, - make_custom_action, make_user_auth_headers, role, expected_status, @@ -323,9 +306,9 @@ def test_custom_button_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_update_permissions( @@ -359,7 +342,11 @@ def test_custom_button_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_custom_button_list_permissions( make_organization_and_user_with_plugin_token, @@ -388,7 +375,11 @@ def test_custom_button_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_custom_button_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -418,9 +409,9 @@ def test_custom_button_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_delete_permissions( diff --git a/engine/apps/api/tests/test_escalation_chain.py b/engine/apps/api/tests/test_escalation_chain.py index ee88fa18..abe925f8 100644 --- a/engine/apps/api/tests/test_escalation_chain.py +++ b/engine/apps/api/tests/test_escalation_chain.py @@ -24,7 +24,7 @@ def test_delete_escalation_chain(escalation_chain_internal_api_setup, make_user_ @pytest.mark.django_db -def test_update_escalation_chain(escalation_chain_internal_api_setup, make_user_auth_headers, make_organization): +def test_update_escalation_chain(escalation_chain_internal_api_setup, make_user_auth_headers): user, token, escalation_chain = escalation_chain_internal_api_setup client = APIClient() url = reverse("api-internal:escalation_chain-detail", kwargs={"pk": escalation_chain.public_primary_key}) diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index f1e4c804..54fc2301 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import EscalationPolicy -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -93,9 +93,9 @@ def test_move_to_position(escalation_policy_internal_api_setup, make_user_auth_h @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_create_permissions( @@ -130,9 +130,9 @@ def test_escalation_policy_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_update_permissions( @@ -171,9 +171,9 @@ def test_escalation_policy_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_list_permissions( @@ -208,9 +208,9 @@ def test_escalation_policy_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_retrieve_permissions( @@ -245,9 +245,9 @@ def test_escalation_policy_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_delete_permissions( @@ -282,9 +282,9 @@ def test_escalation_policy_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_escalation_options_permissions( @@ -319,9 +319,9 @@ def test_escalation_policy_escalation_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_delay_options_permissions( @@ -357,9 +357,9 @@ def test_escalation_policy_delay_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_move_to_position_permissions( diff --git a/engine/apps/api/tests/test_gitops.py b/engine/apps/api/tests/test_gitops.py index ca196433..0152f5e3 100644 --- a/engine/apps/api/tests/test_gitops.py +++ b/engine/apps/api/tests/test_gitops.py @@ -3,16 +3,16 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_terraform_gitops_permissions( @@ -22,7 +22,7 @@ def test_terraform_gitops_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + organization, user, token = make_organization_and_user_with_plugin_token(role=role) make_escalation_chain(organization) client = APIClient() @@ -38,15 +38,15 @@ def test_terraform_gitops_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_terraform_state_permissions( make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status ): - _, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role=role) client = APIClient() url = reverse("api-internal:terraform_imports") diff --git a/engine/apps/api/tests/test_integration_heartbeat.py b/engine/apps/api/tests/test_integration_heartbeat.py index 048b5121..8954b74a 100644 --- a/engine/apps/api/tests/test_integration_heartbeat.py +++ b/engine/apps/api/tests/test_integration_heartbeat.py @@ -8,8 +8,8 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.heartbeat.models import IntegrationHeartBeat -from common.constants.role import Role MOCK_LAST_HEARTBEAT_TIME_VERBAL = "a moment" @@ -151,7 +151,7 @@ def test_create_empty_alert_receive_channel_integration_heartbeat( integration_heartbeat_internal_api_setup, make_user_auth_headers, ): - user, token, alert_receive_channel, integration_heartbeat = integration_heartbeat_internal_api_setup + user, token, _, _ = integration_heartbeat_internal_api_setup client = APIClient() url = reverse("api-internal:integration_heartbeat-list") @@ -185,9 +185,39 @@ def test_update_integration_heartbeat( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_integration_heartbeat_create_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:integration_heartbeat-list") + + with patch( + "apps.api.views.integration_heartbeat.IntegrationHeartBeatView.create", + return_value=Response( + status=status.HTTP_200_OK, + ), + ): + response = client.post(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_integration_heartbeat_update_permissions( @@ -223,7 +253,11 @@ def test_integration_heartbeat_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_integration_heartbeat_list_permissions( make_organization_and_user_with_plugin_token, @@ -255,9 +289,40 @@ def test_integration_heartbeat_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], +) +def test_integration_heartbeat_timeout_options_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:integration_heartbeat-timeout-options") + + with patch( + "apps.api.views.integration_heartbeat.IntegrationHeartBeatView.timeout_options", + return_value=Response( + status=status.HTTP_200_OK, + ), + ): + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_integration_heartbeat_retrieve_permissions( diff --git a/engine/apps/api/tests/test_maintenance.py b/engine/apps/api/tests/test_maintenance.py index dc140d67..6441d063 100644 --- a/engine/apps/api/tests/test_maintenance.py +++ b/engine/apps/api/tests/test_maintenance.py @@ -6,6 +6,8 @@ from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel from apps.user_management.models import Organization +# TODO: should probably modify these tests to take into account new rbac permissions + @pytest.fixture() def maintenance_internal_api_setup( @@ -23,7 +25,7 @@ def maintenance_internal_api_setup( def test_start_maintenance_integration( maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() url = reverse("api-internal:start_maintenance") @@ -50,7 +52,7 @@ def test_stop_maintenance_integration( mock_start_disable_maintenance_task, make_user_auth_headers, ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() mode = AlertReceiveChannel.MAINTENANCE duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds @@ -161,7 +163,7 @@ def test_maintenances_list( def test_empty_maintenances_list( maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() url = reverse("api-internal:maintenance") response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index 8d5db17f..0235775d 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -7,8 +7,8 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb -from common.constants.role import Role @pytest.fixture() @@ -26,7 +26,7 @@ def on_call_shift_internal_api_setup( @pytest.mark.django_db def test_create_on_call_shift_rotation(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -58,7 +58,7 @@ def test_create_on_call_shift_rotation(on_call_shift_internal_api_setup, make_us @pytest.mark.django_db def test_create_on_call_shift_override(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -98,7 +98,7 @@ def test_get_on_call_shift( make_on_call_shift, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = timezone.now().replace(microsecond=0) @@ -144,7 +144,7 @@ def test_list_on_call_shift( make_on_call_shift, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = timezone.now().replace(microsecond=0) @@ -270,7 +270,7 @@ def test_update_future_on_call_shift( make_user_auth_headers, ): """Test updating the shift that has not started (rotation_start > now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0) @@ -337,7 +337,7 @@ def test_update_started_on_call_shift( ): """Test updating the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -409,7 +409,7 @@ def test_update_old_on_call_shift_with_future_version( make_user_auth_headers, ): """Test updating the shift that has the newer version (updated_shift is not None)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() now = timezone.now().replace(microsecond=0) @@ -498,7 +498,7 @@ def test_update_started_on_call_shift_title( ): """Test updating the title for the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -560,7 +560,7 @@ def test_delete_started_on_call_shift( ): """Test deleting the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -598,7 +598,7 @@ def test_delete_future_on_call_shift( ): """Test deleting the shift that has not started (rotation_start > now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0) @@ -631,7 +631,7 @@ def test_create_on_call_shift_invalid_data_rotation_start( on_call_shift_internal_api_setup, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -660,7 +660,7 @@ def test_create_on_call_shift_invalid_data_rotation_start( @pytest.mark.django_db def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -713,7 +713,7 @@ def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setu @pytest.mark.django_db def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -763,7 +763,7 @@ def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_set @pytest.mark.django_db def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -813,7 +813,7 @@ def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_s @pytest.mark.django_db def test_create_on_call_shift_invalid_data_shift_end(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -866,7 +866,7 @@ def test_create_on_call_shift_invalid_data_rolling_users( on_call_shift_internal_api_setup, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -894,7 +894,7 @@ def test_create_on_call_shift_invalid_data_rolling_users( @pytest.mark.django_db def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -925,9 +925,9 @@ def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_s @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_create_permissions( @@ -936,7 +936,7 @@ def test_on_call_shift_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() @@ -957,9 +957,9 @@ def test_on_call_shift_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_update_permissions( @@ -1005,9 +1005,9 @@ def test_on_call_shift_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_list_permissions( @@ -1016,7 +1016,7 @@ def test_on_call_shift_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:oncall_shifts-list") @@ -1036,9 +1036,9 @@ def test_on_call_shift_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_retrieve_permissions( @@ -1079,9 +1079,9 @@ def test_on_call_shift_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_delete_permissions( @@ -1122,9 +1122,9 @@ def test_on_call_shift_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_frequency_options_permissions( @@ -1153,9 +1153,9 @@ def test_on_call_shift_frequency_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_days_options_permissions( @@ -1184,9 +1184,9 @@ def test_on_call_shift_days_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_preview_permissions( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index ed13fb2c..518c9f5a 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -6,30 +6,25 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_current_team_retrieve_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - org = make_organization() - tester = make_user_for_organization(org, role=role) - _, token = make_token_for_organization(org) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-current-team") @@ -48,23 +43,18 @@ def test_current_team_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_update_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - org = make_organization() - tester = make_user_for_organization(org, role=role) - _, token = make_token_for_organization(org) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-current-team") @@ -84,9 +74,9 @@ def test_current_team_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_get_telegram_verification_code_permissions( @@ -95,8 +85,7 @@ def test_current_team_get_telegram_verification_code_permissions( role, expected_status, ): - organization, tester, token = make_organization_and_user_with_plugin_token(role) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-get-telegram-verification-code") @@ -109,9 +98,9 @@ def test_current_team_get_telegram_verification_code_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_get_channel_verification_code_permissions( @@ -120,8 +109,7 @@ def test_current_team_get_channel_verification_code_permissions( role, expected_status, ): - organization, tester, token = make_organization_and_user_with_plugin_token(role) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=TESTONLY" @@ -135,8 +123,7 @@ def test_current_team_get_channel_verification_code_ok( make_organization_and_user_with_plugin_token, make_user_auth_headers, ): - organization, tester, token = make_organization_and_user_with_plugin_token(Role.ADMIN) - + organization, tester, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=TESTONLY" @@ -156,8 +143,7 @@ def test_current_team_get_channel_verification_code_invalid( make_organization_and_user_with_plugin_token, make_user_auth_headers, ): - organization, tester, token = make_organization_and_user_with_plugin_token(Role.ADMIN) - + _, tester, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=INVALID" diff --git a/engine/apps/api/tests/test_postmortem_messages.py b/engine/apps/api/tests/test_postmortem_messages.py index fe45ded0..e2877ce0 100644 --- a/engine/apps/api/tests/test_postmortem_messages.py +++ b/engine/apps/api/tests/test_postmortem_messages.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import ResolutionNote -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @@ -212,9 +212,9 @@ def test_delete_resolution_note( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_create_permissions( @@ -224,7 +224,7 @@ def test_resolution_note_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:resolution_note-list") @@ -245,9 +245,9 @@ def test_resolution_note_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_update_permissions( @@ -260,7 +260,7 @@ def test_resolution_note_update_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( @@ -289,9 +289,9 @@ def test_resolution_note_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_delete_permissions( @@ -304,7 +304,7 @@ def test_resolution_note_delete_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( @@ -331,9 +331,9 @@ def test_resolution_note_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_resolution_note_list_permissions( @@ -343,7 +343,7 @@ def test_resolution_note_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:resolution_note-list") @@ -363,9 +363,9 @@ def test_resolution_note_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_resolution_note_detail_permissions( @@ -378,7 +378,7 @@ def test_resolution_note_detail_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( diff --git a/engine/apps/api/tests/test_public_api_tokens.py b/engine/apps/api/tests/test_public_api_tokens.py new file mode 100644 index 00000000..5984a61e --- /dev/null +++ b/engine/apps/api/tests/test_public_api_tokens.py @@ -0,0 +1,115 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.api.permissions import LegacyAccessControlRole + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_retrieve_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + api_token, _ = make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-detail", kwargs={"pk": api_token.id}) + response = client.get(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_list_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-list") + response = client.get(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_create_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, plugin_token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:api_token-list") + response = client.post( + url, + data={ + "name": "helloooo", + }, + format="json", + **make_user_auth_headers(user, plugin_token), + ) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_delete_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + api_token, _ = make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-detail", kwargs={"pk": api_token.id}) + response = client.delete(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status diff --git a/engine/apps/api/tests/test_schedule_export.py b/engine/apps/api/tests/test_schedule_export.py index ecbdec7e..02dc6a5b 100644 --- a/engine/apps/api/tests/test_schedule_export.py +++ b/engine/apps/api/tests/test_schedule_export.py @@ -3,9 +3,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.models import ScheduleExportAuthToken from apps.schedules.models import OnCallScheduleICal -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" # noqa @@ -14,9 +14,9 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_get_schedule_export_token( @@ -26,8 +26,7 @@ def test_get_schedule_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -50,9 +49,9 @@ def test_get_schedule_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_export_token_not_found( @@ -62,8 +61,7 @@ def test_schedule_export_token_not_found( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -84,9 +82,9 @@ def test_schedule_export_token_not_found( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_201_CREATED), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_create_export_token( @@ -96,8 +94,7 @@ def test_schedule_create_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -118,9 +115,9 @@ def test_schedule_create_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_delete_export_token( @@ -130,8 +127,7 @@ def test_schedule_delete_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 9c73027d..64185118 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -10,6 +10,7 @@ from rest_framework.serializers import ValidationError from rest_framework.test import APIClient from apps.alerts.models import EscalationPolicy +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import memoized_users_in_ical from apps.schedules.models import ( CustomOnCallShift, @@ -18,7 +19,6 @@ from apps.schedules.models import ( OnCallScheduleICal, OnCallScheduleWeb, ) -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -405,7 +405,7 @@ def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers @pytest.mark.django_db def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _, _ = schedule_internal_api_setup + user, token, _, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:custom_button-list") with patch( @@ -422,6 +422,33 @@ def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_aut assert response.status_code == status.HTTP_400_BAD_REQUEST +@pytest.mark.django_db +@pytest.mark.parametrize("calendar_type", [0, 2]) +def test_create_schedule_invalid_time_zone(schedule_internal_api_setup, make_user_auth_headers, calendar_type): + user, token, _, _, _, _ = schedule_internal_api_setup + client = APIClient() + url = reverse("api-internal:schedule-list") + data = { + "name": "created_web_schedule", + "type": calendar_type, + "time_zone": "asdfasdfasdf", + "slack_channel_id": None, + "user_group": None, + "team": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"time_zone": ["Invalid timezone"]} + + @pytest.mark.django_db def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup @@ -479,6 +506,24 @@ def test_update_web_schedule(schedule_internal_api_setup, make_user_auth_headers assert updated_instance.name == "updated_web_schedule" +@pytest.mark.django_db +@pytest.mark.parametrize("calendar_type", [0, 2]) +def test_update_schedule_invalid_time_zone(schedule_internal_api_setup, make_user_auth_headers, calendar_type): + user, token, *calendars, _ = schedule_internal_api_setup + schedule = calendars[calendar_type] + + client = APIClient() + + url = reverse("api-internal:schedule-detail", kwargs={"pk": schedule.public_primary_key}) + data = {"name": "updated_web_schedule", "type": calendar_type, "time_zone": "asdfasdfasdf"} + response = client.put( + url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"time_zone": ["Invalid timezone"]} + + @pytest.mark.django_db def test_delete_schedule(schedule_internal_api_setup, make_user_auth_headers): user, token, calendar_schedule, ical_schedule, _, _ = schedule_internal_api_setup @@ -1062,7 +1107,7 @@ def test_merging_same_shift_events( user_a = make_user_for_organization(organization) user_b = make_user_for_organization(organization) - user_c = make_user_for_organization(organization, role=Role.VIEWER) + user_c = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) # clear users pks <-> organization cache (persisting between tests) memoized_users_in_ical.cache_clear() @@ -1158,9 +1203,9 @@ def test_filter_events_invalid_type( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_create_permissions( @@ -1170,7 +1215,7 @@ def test_schedule_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1196,9 +1241,9 @@ def test_schedule_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_update_permissions( @@ -1208,7 +1253,7 @@ def test_schedule_update_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1237,7 +1282,11 @@ def test_schedule_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_schedule_list_permissions( make_organization_and_user_with_plugin_token, @@ -1246,7 +1295,7 @@ def test_schedule_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1271,7 +1320,11 @@ def test_schedule_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_schedule_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -1280,7 +1333,7 @@ def test_schedule_retrieve_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1306,9 +1359,9 @@ def test_schedule_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_delete_permissions( @@ -1318,7 +1371,7 @@ def test_schedule_delete_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1344,9 +1397,9 @@ def test_schedule_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_events_permissions( @@ -1356,7 +1409,7 @@ def test_events_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1382,9 +1435,9 @@ def test_events_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_reload_ical_permissions( @@ -1394,7 +1447,7 @@ def test_reload_ical_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1420,9 +1473,9 @@ def test_reload_ical_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_notify_oncall_shift_freq_options_permissions( @@ -1432,7 +1485,7 @@ def test_schedule_notify_oncall_shift_freq_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-notify-oncall-shift-freq-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -1444,9 +1497,9 @@ def test_schedule_notify_oncall_shift_freq_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_notify_empty_oncall_options_permissions( @@ -1456,7 +1509,7 @@ def test_schedule_notify_empty_oncall_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-notify-empty-oncall-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -1468,9 +1521,9 @@ def test_schedule_notify_empty_oncall_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_mention_options_permissions( @@ -1480,7 +1533,7 @@ def test_schedule_mention_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-mention-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_set_general_log_channel.py b/engine/apps/api/tests/test_set_general_log_channel.py index 703dd324..cdcce180 100644 --- a/engine/apps/api/tests/test_set_general_log_channel.py +++ b/engine/apps/api/tests/test_set_general_log_channel.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole # Testing permissions, not view itself. So mock is ok here @@ -14,13 +14,16 @@ from common.constants.role import Role @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_set_general_log_channel_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() diff --git a/engine/apps/api/tests/test_slack_channels.py b/engine/apps/api/tests/test_slack_channels.py index 37d2c05c..70a083b1 100644 --- a/engine/apps/api/tests/test_slack_channels.py +++ b/engine/apps/api/tests/test_slack_channels.py @@ -6,20 +6,23 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_slack_channels_list_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() @@ -40,13 +43,17 @@ def test_slack_channels_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_slack_channels_detail_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, make_slack_channel, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_slack_channel, + role, + expected_status, ): organization, user, token = make_organization_and_user_with_plugin_token(role) slack_channel = make_slack_channel(organization.slack_team_identity) diff --git a/engine/apps/api/tests/test_slack_team_settings.py b/engine/apps/api/tests/test_slack_team_settings.py index 31df5d83..9de0e2ba 100644 --- a/engine/apps/api/tests/test_slack_team_settings.py +++ b/engine/apps/api/tests/test_slack_team_settings.py @@ -6,16 +6,16 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_slack_settings_permissions( @@ -24,7 +24,7 @@ def test_get_slack_settings_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:slack-settings") @@ -43,9 +43,9 @@ def test_get_slack_settings_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_update_slack_settings_permissions( @@ -54,7 +54,7 @@ def test_update_slack_settings_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:slack-settings") @@ -73,9 +73,9 @@ def test_update_slack_settings_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_acknowledge_remind_options_permissions( @@ -84,7 +84,7 @@ def test_get_acknowledge_remind_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:acknowledge-reminder-options") @@ -103,9 +103,9 @@ def test_get_acknowledge_remind_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_unacknowledge_timeout_options_permissions( @@ -114,7 +114,7 @@ def test_get_unacknowledge_timeout_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:unacknowledge-timeout-options") diff --git a/engine/apps/api/tests/test_subscription.py b/engine/apps/api/tests/test_subscription.py index ef61c949..2753784b 100644 --- a/engine/apps/api/tests/test_subscription.py +++ b/engine/apps/api/tests/test_subscription.py @@ -6,16 +6,16 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_subscription_retrieve_permissions( diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index 40df30d8..acdfaddb 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -3,9 +3,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import OnCallScheduleCalendar from apps.user_management.models import Team -from common.constants.role import Role GENERAL_TEAM = Team(public_primary_key=None, name="General", email=None, avatar_url=None) @@ -64,28 +64,24 @@ def test_list_teams_for_non_member( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_list_teams_permissions( - make_organization, - make_token_for_organization, - make_user_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - _, token = make_token_for_organization(organization) - user = make_user_for_organization(organization, role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:team-list") response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected_status @pytest.mark.django_db diff --git a/engine/apps/api/tests/test_telegram_channel.py b/engine/apps/api/tests/test_telegram_channel.py index 6bf26b9c..fa340ec4 100644 --- a/engine/apps/api/tests/test_telegram_channel.py +++ b/engine/apps/api/tests/test_telegram_channel.py @@ -3,14 +3,14 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db def test_not_authorized(make_organization_and_user_with_plugin_token, make_telegram_channel): client = APIClient() - organization, user, _ = make_organization_and_user_with_plugin_token() + organization, _, _ = make_organization_and_user_with_plugin_token() telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-list") @@ -34,9 +34,9 @@ def test_not_authorized(make_organization_and_user_with_plugin_token, make_teleg @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_list_telegram_channels_permissions( @@ -46,8 +46,7 @@ def test_list_telegram_channels_permissions( expected_status, ): client = APIClient() - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:telegram_channel-list") response = client.get(url, **make_user_auth_headers(user, token)) @@ -59,9 +58,9 @@ def test_list_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_telegram_channels_permissions( @@ -72,8 +71,7 @@ def test_get_telegram_channels_permissions( expected_status, ): client = APIClient() - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-detail", kwargs={"pk": telegram_channel.public_primary_key}) @@ -86,9 +84,9 @@ def test_get_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_delete_telegram_channels_permissions( @@ -100,7 +98,7 @@ def test_delete_telegram_channels_permissions( ): client = APIClient() - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-detail", kwargs={"pk": telegram_channel.public_primary_key}) @@ -113,9 +111,9 @@ def test_delete_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_set_default_telegram_channels_permissions( @@ -127,8 +125,7 @@ def test_set_default_telegram_channels_permissions( ): client = APIClient() - organization, user, token = make_organization_and_user_with_plugin_token(role=role) - + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-set-default", kwargs={"pk": telegram_channel.public_primary_key}) diff --git a/engine/apps/api/tests/test_terraform_renderer.py b/engine/apps/api/tests/test_terraform_renderer.py index 16e5f654..ffa33155 100644 --- a/engine/apps/api/tests/test_terraform_renderer.py +++ b/engine/apps/api/tests/test_terraform_renderer.py @@ -18,7 +18,7 @@ def test_get_terraform_file( @pytest.mark.django_db def test_get_terraform_imports(make_organization_and_user_with_plugin_token, make_user_auth_headers): - organization, user, token = make_organization_and_user_with_plugin_token() + _, user, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:terraform_imports") response = client.get(url, format="text/plain", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 54a79ad7..ee8c74cc 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -8,10 +8,9 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from apps.base.constants import ADMIN_PERMISSIONS, EDITOR_PERMISSIONS +from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING, LegacyAccessControlRole from apps.base.models import UserNotificationPolicy from apps.user_management.models.user import default_working_hours -from common.constants.role import Role @pytest.mark.django_db @@ -68,6 +67,7 @@ def test_update_user_cant_change_email_and_username( "email": admin.email, "hide_phone_number": False, "username": admin.username, + "name": admin.name, "role": admin.role, "timezone": None, "working_hours": default_working_hours(), @@ -80,10 +80,11 @@ def test_update_user_cant_change_email_and_username( } }, "cloud_connection_status": 0, - "permissions": ADMIN_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "avatar_full": admin.avatar_full_url, } response = client.put(url, data, format="json", **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK @@ -99,7 +100,7 @@ def test_list_users( ): organization = make_organization() admin = make_user_for_organization(organization) - editor = make_user_for_organization(organization, role=Role.EDITOR) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) _, token = make_token_for_organization(organization) client = APIClient() @@ -117,6 +118,7 @@ def test_list_users( "email": admin.email, "hide_phone_number": False, "username": admin.username, + "name": admin.name, "role": admin.role, "timezone": None, "working_hours": default_working_hours(), @@ -128,10 +130,11 @@ def test_list_users( "user": admin.username, } }, - "permissions": ADMIN_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "avatar_full": admin.avatar_full_url, "cloud_connection_status": 0, }, { @@ -141,6 +144,7 @@ def test_list_users( "email": editor.email, "hide_phone_number": False, "username": editor.username, + "name": editor.name, "role": editor.role, "timezone": None, "working_hours": default_working_hours(), @@ -152,10 +156,11 @@ def test_list_users( "user": editor.username, } }, - "permissions": EDITOR_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[editor.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, + "avatar_full": editor.avatar_full_url, "cloud_connection_status": 0, }, ], @@ -229,22 +234,18 @@ def test_notification_chain_verbal( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_update_self_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": tester.public_primary_key}) with patch( @@ -262,23 +263,20 @@ def test_user_update_self_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_update_other_permissions( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) @@ -293,22 +291,18 @@ def test_user_update_other_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_list_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-list") @@ -327,22 +321,18 @@ def test_user_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_user_detail_self_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": tester.public_primary_key}) @@ -361,23 +351,20 @@ def test_user_detail_self_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_detail_other_permissions( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) @@ -390,22 +377,18 @@ def test_user_detail_other_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_own_verification_code( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": tester.public_primary_key}) @@ -424,23 +407,20 @@ def test_user_get_own_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_other_verification_code( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key}) @@ -454,22 +434,18 @@ def test_user_get_other_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_verify_own_phone( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": tester.public_primary_key}) @@ -493,23 +469,20 @@ Tests below are outdated @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_verify_another_phone( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, tester, token = make_organization_and_user_with_plugin_token(role) + other_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": other_user.public_primary_key}) @@ -524,22 +497,18 @@ def test_user_verify_another_phone( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_own_telegram_verification_code( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": tester.public_primary_key}) @@ -552,23 +521,20 @@ def test_user_get_own_telegram_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_another_telegram_verification_code( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, tester, token = make_organization_and_user_with_plugin_token(role) + other_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": other_user.public_primary_key}) @@ -579,270 +545,16 @@ def test_user_get_another_telegram_verification_code( @pytest.mark.django_db def test_admin_can_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=Role.ADMIN) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) client = APIClient() data = { "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "updated_test_username", - "unverified_phone_number": "+1234567890", - "slack_login": "", - } - url = reverse("api-internal:user-detail", kwargs={"pk": other_user.public_primary_key}) - response = client.put(url, format="json", data=data, **make_user_auth_headers(tester, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_update_himself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "updated_test_username", - "unverified_phone_number": "+1234567890", - "slack_login": "", - } - - url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) - response = client.put(url, format="json", data=data, **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - - url = reverse("api-internal:user-list") - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - - url = reverse("api-internal:user-detail", kwargs={"pk": editor.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@pytest.mark.django_db -def test_admin_can_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@pytest.mark.django_db -def test_admin_can_get_another_user_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-verification-code", kwargs={"pk": editor.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) -@pytest.mark.django_db -def test_admin_can_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-verify-number", kwargs={"pk": admin.public_primary_key}) - - response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) -@pytest.mark.django_db -def test_admin_can_verify_another_user_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-verify-number", kwargs={"pk": editor.public_primary_key}) - - response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": admin.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": editor.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = ( - reverse("api-internal:user-get-backend-verification-code", kwargs={"pk": editor.public_primary_key}) - + "?backend=TESTONLY" - ) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_unlink_another_user_backend_account( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-unlink-backend", kwargs={"pk": editor.public_primary_key}) + "?backend=TESTONLY" - - response = client.post(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_unlink_another_user_slack_account( - make_organization_with_slack_team_identity, - make_user_for_organization, - make_user_with_slack_user_identity, - make_token_for_organization, - make_user_auth_headers, -): - organization, slack_team_identity = make_organization_with_slack_team_identity() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR - ) - - _, token = make_token_for_organization(organization) - client = APIClient() - url = reverse("api-internal:user-unlink-slack", kwargs={"pk": editor.public_primary_key}) - - response = client.post(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - editor.refresh_from_db() - assert editor.slack_user_identity is None - - -"""Test user permissions""" - - -@pytest.mark.django_db -def test_user_cant_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -850,21 +562,16 @@ def test_user_cant_update_user( url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) response = client.put(url, format="json", data=data, **make_user_auth_headers(second_user, token)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db -def test_user_can_update_themself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_admin_can_update_himself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) client = APIClient() data = { "email": "test@amixr.io", - "role": Role.EDITOR, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -877,49 +584,267 @@ def test_user_can_update_themself( @pytest.mark.django_db -def test_user_can_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_admin_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) client = APIClient() url = reverse("api-internal:user-list") - response = client.get(url, format="json", **make_user_auth_headers(editor, token)) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_detail_users( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@pytest.mark.django_db +def test_admin_can_get_own_verification_code( + mock_verification_start, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@pytest.mark.django_db +def test_admin_can_get_another_user_verification_code( + mock_verification_start, + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@pytest.mark.django_db +def test_admin_can_verify_own_phone( + mocked_verification_check, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) + + response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@pytest.mark.django_db +def test_admin_can_verify_another_user_phone( + mocked_verification_check, + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) + + response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_own_telegram_verification_code( + make_organization_and_user_with_plugin_token, make_user_auth_headers +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_another_user_telegram_verification_code( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_another_user_backend_verification_code( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = ( + reverse("api-internal:user-get-backend-verification-code", kwargs={"pk": first_user.public_primary_key}) + + "?backend=TESTONLY" + ) + + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_unlink_another_user_backend_account( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = ( + reverse("api-internal:user-unlink-backend", kwargs={"pk": first_user.public_primary_key}) + "?backend=TESTONLY" + ) + + response = client.post(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_unlink_another_user_slack_account( + make_organization_with_slack_team_identity, + make_user_for_organization, + make_user_with_slack_user_identity, + make_token_for_organization, + make_user_auth_headers, +): + organization, slack_team_identity = make_organization_with_slack_team_identity() + _, token = make_token_for_organization(organization) + + user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.ADMIN + ) + other_user = make_user_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-unlink-slack", kwargs={"pk": other_user.public_primary_key}) + + response = client.post(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + other_user.refresh_from_db() + assert other_user.slack_user_identity is None + + +"""Test user permissions""" + + +@pytest.mark.django_db +def test_user_cant_update_user( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + data = { + "email": "test@amixr.io", + "username": "updated_test_username", + "unverified_phone_number": "+1234567890", + "slack_login": "", + } + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) + response = client.put(url, format="json", data=data, **make_user_auth_headers(second_user, token)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_user_can_update_themself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + data = { + "email": "test@amixr.io", + "username": "updated_test_username", + "unverified_phone_number": "+1234567890", + "slack_login": "", + } + + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + response = client.put(url, format="json", data=data, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_user_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + + url = reverse("api-internal:user-list") + response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db def test_user_can_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() - url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(editor, token)) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) assert response.status_code == status.HTTP_403_FORBIDDEN @patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) @pytest.mark.django_db def test_user_can_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) @@ -932,15 +857,12 @@ def test_user_can_get_own_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_verification_code( mock_verification_start, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -952,15 +874,9 @@ def test_user_cant_get_another_user_verification_code( @patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) @pytest.mark.django_db def test_user_can_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) @@ -973,15 +889,12 @@ def test_user_can_verify_own_phone( @pytest.mark.django_db def test_user_cant_verify_another_user_phone( mocked_verification_check, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) @@ -992,11 +905,9 @@ def test_user_cant_verify_another_user_phone( @pytest.mark.django_db def test_user_can_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1007,12 +918,12 @@ def test_user_can_get_own_telegram_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1023,11 +934,9 @@ def test_user_cant_get_another_user_telegram_verification_code( @pytest.mark.django_db def test_user_can_get_own_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1048,12 +957,12 @@ def test_user_can_get_own_backend_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1073,8 +982,8 @@ def test_user_can_unlink_own_slack_account( make_user_auth_headers, ): organization, slack_team_identity = make_organization_with_slack_team_identity() - user, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR + user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.EDITOR ) _, token = make_token_for_organization(organization) @@ -1088,12 +997,8 @@ def test_user_can_unlink_own_slack_account( @pytest.mark.django_db -def test_user_can_unlink_backend_own_account( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_user_can_unlink_backend_own_account(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1104,12 +1009,8 @@ def test_user_can_unlink_backend_own_account( @pytest.mark.django_db -def test_user_unlink_backend_invalid_backend_id( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_user_unlink_backend_invalid_backend_id(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=INVALID" @@ -1121,11 +1022,9 @@ def test_user_unlink_backend_invalid_backend_id( @pytest.mark.django_db def test_user_unlink_backend_backend_account_not_found( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1143,11 +1042,12 @@ def test_user_cant_unlink_slack_another_user( make_user_auth_headers, ): organization, slack_team_identity = make_organization_with_slack_team_identity() - first_user, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR + + first_user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_1", role=LegacyAccessControlRole.EDITOR ) - second_user, slack_user_identity_2 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_2", role=Role.EDITOR + second_user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.EDITOR ) _, token = make_token_for_organization(organization) @@ -1162,12 +1062,10 @@ def test_user_cant_unlink_slack_another_user( @pytest.mark.django_db def test_user_cant_unlink_backend__another_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1181,40 +1079,16 @@ def test_user_cant_unlink_backend__another_user( """Test stakeholder permissions""" -@pytest.mark.django_db -def test_viewer_cant_create_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-list") - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "test_username", - "unverified_phone_number": None, - "slack_login": "", - } - response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_403_FORBIDDEN - - @pytest.mark.django_db def test_viewer_cant_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) data = { "email": "test@amixr.io", - "role": Role.EDITOR, + "role": LegacyAccessControlRole.EDITOR, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -1228,16 +1102,12 @@ def test_viewer_cant_update_user( @pytest.mark.django_db -def test_viewer_cant_update_himself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) +def test_viewer_cant_update_himself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) data = { "email": "test@amixr.io", - "role": Role.VIEWER, + "role": LegacyAccessControlRole.VIEWER, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -1251,12 +1121,8 @@ def test_viewer_cant_update_himself( @pytest.mark.django_db -def test_viewer_cant_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) +def test_viewer_cant_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-list") @@ -1267,12 +1133,10 @@ def test_viewer_cant_list_users( @pytest.mark.django_db def test_viewer_cant_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) @@ -1284,15 +1148,9 @@ def test_viewer_cant_detail_users( @patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) @pytest.mark.django_db def test_viewer_cant_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1305,15 +1163,12 @@ def test_viewer_cant_get_own_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_verification_code( mock_verification_start, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1325,15 +1180,9 @@ def test_viewer_cant_get_another_user_verification_code( @patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) @pytest.mark.django_db def test_viewer_cant_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) @@ -1346,15 +1195,12 @@ def test_viewer_cant_verify_own_phone( @pytest.mark.django_db def test_viewer_cant_verify_another_user_phone( mocked_verification_check, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) @@ -1365,11 +1211,9 @@ def test_viewer_cant_verify_another_user_phone( @pytest.mark.django_db def test_viewer_cant_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1380,12 +1224,10 @@ def test_viewer_cant_get_own_telegram_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1398,34 +1240,30 @@ def test_viewer_cant_get_another_user_telegram_verification_code( @pytest.mark.parametrize( "role,expected_status,initial_unverified_number,initial_verified_number", [ - (Role.ADMIN, status.HTTP_200_OK, "+1234567890", None), - (Role.EDITOR, status.HTTP_200_OK, "+1234567890", None), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.ADMIN, status.HTTP_200_OK, None, "+1234567890"), - (Role.EDITOR, status.HTTP_200_OK, None, "+1234567890"), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), ], ) def test_forget_own_number( - make_organization, - make_team, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, initial_unverified_number, initial_verified_number, ): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) + organization, admin, token = make_organization_and_user_with_plugin_token() user = make_user_for_organization( organization, role=role, unverified_phone_number=initial_unverified_number, _verified_phone_number=initial_verified_number, ) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key}) @@ -1450,17 +1288,16 @@ def test_forget_own_number( @pytest.mark.parametrize( "role,expected_status,initial_unverified_number,initial_verified_number", [ - (Role.ADMIN, status.HTTP_200_OK, "+1234567890", None), - (Role.EDITOR, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.ADMIN, status.HTTP_200_OK, None, "+1234567890"), - (Role.EDITOR, status.HTTP_403_FORBIDDEN, None, "+1234567890"), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), ], ) def test_forget_other_number( make_organization, - make_team, make_user_for_organization, make_token_for_organization, make_user_auth_headers, @@ -1470,26 +1307,26 @@ def test_forget_other_number( initial_verified_number, ): organization = make_organization() - user = make_user_for_organization( - organization, - role=Role.ADMIN, - unverified_phone_number=initial_unverified_number, - _verified_phone_number=initial_verified_number, - ) - other_user = make_user_for_organization(organization, role=role) _, token = make_token_for_organization(organization) + admin = make_user_for_organization( + organization, unverified_phone_number=initial_unverified_number, _verified_phone_number=initial_verified_number + ) + other_user = make_user_for_organization(organization, role=role) + admin_primary_key = admin.public_primary_key + client = APIClient() - url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key}) + url = reverse("api-internal:user-forget-number", kwargs={"pk": admin_primary_key}) with patch( "apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None ): response = client.put(url, None, format="json", **make_user_auth_headers(other_user, token)) assert response.status_code == expected_status - user_detail_url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) - response = client.get(user_detail_url, None, format="json", **make_user_auth_headers(user, token)) + user_detail_url = reverse("api-internal:user-detail", kwargs={"pk": admin_primary_key}) + response = client.get(user_detail_url, None, format="json", **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK + if expected_status == status.HTTP_200_OK: assert not response.json()["unverified_phone_number"] assert not response.json()["verified_phone_number"] @@ -1500,11 +1337,9 @@ def test_forget_other_number( @pytest.mark.django_db def test_viewer_cant_get_own_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1518,12 +1353,10 @@ def test_viewer_cant_get_own_backend_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1536,13 +1369,8 @@ def test_viewer_cant_get_another_user_backend_verification_code( @pytest.mark.django_db -def test_viewer_cant_unlink_backend_own_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) - +def test_viewer_cant_unlink_backend_own_user(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1552,12 +1380,10 @@ def test_viewer_cant_unlink_backend_own_user( @pytest.mark.django_db def test_viewer_cant_unlink_backend_another_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1569,12 +1395,8 @@ def test_viewer_cant_unlink_backend_another_user( @pytest.mark.django_db -def test_change_timezone( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_change_timezone(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1589,12 +1411,8 @@ def test_change_timezone( @pytest.mark.django_db @pytest.mark.parametrize("timezone", ["", 1, "NotATimezone"]) -def test_invalid_timezone( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers, timezone -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_invalid_timezone(make_organization_and_user_with_plugin_token, make_user_auth_headers, timezone): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1606,12 +1424,8 @@ def test_invalid_timezone( @pytest.mark.django_db -def test_change_working_hours( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_change_working_hours(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1645,15 +1459,9 @@ def test_change_working_hours( ], ) def test_invalid_working_hours( - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, - working_hours_extra, + make_organization_and_user_with_plugin_token, make_user_auth_headers, working_hours_extra ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) diff --git a/engine/apps/api/tests/test_user_groups.py b/engine/apps/api/tests/test_user_groups.py index ce7494a1..2e45e727 100644 --- a/engine/apps/api/tests/test_user_groups.py +++ b/engine/apps/api/tests/test_user_groups.py @@ -3,7 +3,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @@ -52,13 +52,16 @@ def test_usergroup_list_without_slack_installed( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_usergroup_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() diff --git a/engine/apps/api/tests/test_user_notification_policy.py b/engine/apps/api/tests/test_user_notification_policy.py index 1eb39e61..4bc9c306 100644 --- a/engine/apps/api/tests/test_user_notification_policy.py +++ b/engine/apps/api/tests/test_user_notification_policy.py @@ -6,8 +6,8 @@ from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.base.models import UserNotificationPolicy -from common.constants.role import Role DEFAULT_NOTIFICATION_CHANNEL = UserNotificationPolicy.NotificationChannel.SLACK @@ -17,7 +17,7 @@ def user_notification_policy_internal_api_setup( make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_notification_policy ): organization, admin, token = make_organization_and_user_with_plugin_token() - user = make_user_for_organization(organization, Role.EDITOR) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) wait_notification_step = make_user_notification_policy( admin, UserNotificationPolicy.Step.WAIT, wait_delay=timezone.timedelta(minutes=15), important=False @@ -49,7 +49,7 @@ def user_notification_policy_internal_api_setup( @pytest.mark.django_db def test_create_notification_policy(user_notification_policy_internal_api_setup, make_user_auth_headers): - token, steps, users = user_notification_policy_internal_api_setup + token, _, users = user_notification_policy_internal_api_setup admin, _ = users client = APIClient() url = reverse("api-internal:notification_policy-list") @@ -69,7 +69,7 @@ def test_create_notification_policy(user_notification_policy_internal_api_setup, def test_admin_can_create_notification_policy_for_user( user_notification_policy_internal_api_setup, make_user_auth_headers ): - token, steps, users = user_notification_policy_internal_api_setup + token, _, users = user_notification_policy_internal_api_setup admin, user = users client = APIClient() url = reverse("api-internal:notification_policy-list") diff --git a/engine/apps/api/tests/test_user_schedule_export.py b/engine/apps/api/tests/test_user_schedule_export.py index a465a934..fd467477 100644 --- a/engine/apps/api/tests/test_user_schedule_export.py +++ b/engine/apps/api/tests/test_user_schedule_export.py @@ -3,8 +3,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.models import UserScheduleExportAuthToken -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" # noqa @@ -13,9 +13,9 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_get_user_schedule_export_token( @@ -24,8 +24,7 @@ def test_get_user_schedule_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user, @@ -45,9 +44,9 @@ def test_get_user_schedule_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_export_token_not_found( @@ -56,8 +55,7 @@ def test_user_schedule_export_token_not_found( role, expected_status, ): - - _, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:user-export-token", kwargs={"pk": user.public_primary_key}) @@ -72,9 +70,9 @@ def test_user_schedule_export_token_not_found( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_201_CREATED), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_create_export_token( @@ -83,8 +81,7 @@ def test_user_schedule_create_export_token( role, expected_status, ): - - _, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:user-export-token", kwargs={"pk": user.public_primary_key}) @@ -99,9 +96,9 @@ def test_user_schedule_create_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_409_CONFLICT), - (Role.EDITOR, status.HTTP_409_CONFLICT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_409_CONFLICT), + (LegacyAccessControlRole.EDITOR, status.HTTP_409_CONFLICT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_create_multiple_export_tokens_fails( @@ -110,8 +107,7 @@ def test_user_schedule_create_multiple_export_tokens_fails( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user, @@ -131,9 +127,9 @@ def test_user_schedule_create_multiple_export_tokens_fails( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_delete_export_token( @@ -142,8 +138,7 @@ def test_user_schedule_delete_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) instance, _ = UserScheduleExportAuthToken.create_auth_token( user=user, @@ -168,9 +163,9 @@ def test_user_schedule_delete_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_cannot_get_another_users_schedule_token( @@ -179,9 +174,8 @@ def test_user_cannot_get_another_users_schedule_token( role, expected_status, ): - - organization1, user1, _ = make_organization_and_user_with_plugin_token(role=role) - _, user2, token2 = make_organization_and_user_with_plugin_token(role=role) + organization1, user1, _ = make_organization_and_user_with_plugin_token(role) + _, user2, token2 = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user1, @@ -201,9 +195,9 @@ def test_user_cannot_get_another_users_schedule_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_cannot_delete_another_users_schedule_token( @@ -212,9 +206,8 @@ def test_user_cannot_delete_another_users_schedule_token( role, expected_status, ): - - organization1, user1, _ = make_organization_and_user_with_plugin_token(role=role) - _, user2, token2 = make_organization_and_user_with_plugin_token(role=role) + organization1, user1, _ = make_organization_and_user_with_plugin_token(role) + _, user2, token2 = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user1, diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 0af8fb9b..91a49579 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from apps.alerts.constants import ActionSource from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication @@ -160,29 +160,29 @@ class AlertGroupView( MobileAppAuthTokenAuthentication, PluginAuthentication, ) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: ( - *MODIFY_ACTIONS, - "acknowledge", - "unacknowledge", - "resolve", - "unresolve", - "attach", - "unattach", - "silence", - "unsilence", - "bulk_action", - "preview_template", - ), - AnyRole: ( - *READ_ACTIONS, - "stats", - "filters", - "silence_options", - "bulk_action_options", - ), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "stats": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "filters": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "silence_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "bulk_action_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "resolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unresolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "attach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unattach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "silence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unsilence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST], } http_method_names = ["get", "post"] diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 866620a8..e62599fc 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_receive_channel import ( AlertReceiveChannelSerializer, AlertReceiveChannelUpdateSerializer, @@ -66,19 +66,7 @@ class AlertReceiveChannelView( ModelViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "stop_maintenance", "start_maintenance", "change_team"), - IsAdminOrEditor: ("send_demo_alert", "preview_template"), - AnyRole: ( - *READ_ACTIONS, - "integration_options", - "maintenance_duration_options", - "maintenance_mode_options", - "counters", - "counters_per_integration", - ), - } + permission_classes = (IsAuthenticated, RBACPermission) model = AlertReceiveChannel serializer_class = AlertReceiveChannelSerializer @@ -90,6 +78,22 @@ class AlertReceiveChannelView( filterset_class = AlertReceiveChannelFilter + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "integration_options": [RBACPermission.Permissions.INTEGRATIONS_READ], + "counters": [RBACPermission.Permissions.INTEGRATIONS_READ], + "counters_per_integration": [RBACPermission.Permissions.INTEGRATIONS_READ], + "send_demo_alert": [RBACPermission.Permissions.INTEGRATIONS_TEST], + "preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "change_team": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } + def create(self, request, *args, **kwargs): if request.data["integration"] is not None and ( request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES diff --git a/engine/apps/api/views/alert_receive_channel_template.py b/engine/apps/api/views/alert_receive_channel_template.py index ff8cd923..d7963a4c 100644 --- a/engine/apps/api/views/alert_receive_channel_template.py +++ b/engine/apps/api/views/alert_receive_channel_template.py @@ -1,12 +1,14 @@ -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from apps.alerts.models import AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_receive_channel import AlertReceiveChannelTemplatesSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.insight_log import EntityEvent, write_resource_insight_log +from common.jinja_templater.apply_jinja_template import JinjaTemplateError class AlertReceiveChannelTemplateView( @@ -16,11 +18,14 @@ class AlertReceiveChannelTemplateView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } model = AlertReceiveChannel @@ -36,7 +41,10 @@ class AlertReceiveChannelTemplateView( def update(self, request, *args, **kwargs): instance = self.get_object() prev_state = instance.insight_logs_serialized - result = super().update(request, *args, **kwargs) + try: + result = super().update(request, *args, **kwargs) + except JinjaTemplateError as e: + return Response(e.fallback_message, status.HTTP_400_BAD_REQUEST) instance = self.get_object() new_state = instance.insight_logs_serialized write_resource_insight_log( diff --git a/engine/apps/api/views/channel_filter.py b/engine/apps/api/views/channel_filter.py index efe397d1..b1664951 100644 --- a/engine/apps/api/views/channel_filter.py +++ b/engine/apps/api/views/channel_filter.py @@ -6,7 +6,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ChannelFilter -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.channel_filter import ( ChannelFilterCreateSerializer, ChannelFilterSerializer, @@ -23,11 +23,17 @@ from common.insight_log import EntityEvent, write_resource_insight_log class ChannelFilterView(PublicPrimaryKeyMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "move_to_position"), - IsAdminOrEditor: ("send_demo_alert",), - AnyRole: READ_ACTIONS, + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "move_to_position": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "send_demo_alert": [RBACPermission.Permissions.INTEGRATIONS_TEST], } model = ChannelFilter diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py index 09228d27..99037a85 100644 --- a/engine/apps/api/views/custom_button.py +++ b/engine/apps/api/views/custom_button.py @@ -4,7 +4,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet from apps.alerts.models import CustomButton -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.custom_button import CustomButtonSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin @@ -13,10 +13,16 @@ from common.insight_log import EntityEvent, write_resource_insight_log class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "metadata": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], } model = CustomButton diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index 72c73d3a..05cdc216 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -7,7 +7,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.alerts.models import EscalationChain -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.escalation_chain import EscalationChainListSerializer, EscalationChainSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.exceptions import BadRequest @@ -17,11 +17,17 @@ from common.insight_log import EntityEvent, write_resource_insight_log class EscalationChainViewSet(TeamFilteringMixin, PublicPrimaryKeyMixin, ListSerializerMixin, viewsets.ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "copy"), - AnyRole: (*READ_ACTIONS, "details"), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "details": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "copy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], } filter_backends = [SearchFilter] diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index c05a2f0e..a8090648 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationPolicy -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.escalation_policy import ( EscalationPolicyCreateSerializer, EscalationPolicySerializer, @@ -21,15 +21,19 @@ from common.insight_log import EntityEvent, write_resource_insight_log class EscalationPolicyView(PublicPrimaryKeyMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: ( - *READ_ACTIONS, - "escalation_options", - "delay_options", - "num_minutes_in_window_options", - ), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "escalation_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "delay_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "num_minutes_in_window_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "partial_update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "move_to_position": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], } model = EscalationPolicy diff --git a/engine/apps/api/views/integration_heartbeat.py b/engine/apps/api/views/integration_heartbeat.py index fa50e29b..0e1fa96d 100644 --- a/engine/apps/api/views/integration_heartbeat.py +++ b/engine/apps/api/views/integration_heartbeat.py @@ -3,7 +3,7 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.integration_heartbeat import IntegrationHeartBeatSerializer from apps.auth_token.auth import PluginAuthentication from apps.heartbeat.models import IntegrationHeartBeat @@ -20,10 +20,17 @@ class IntegrationHeartBeatView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "activate", "deactivate"), - AnyRole: (*READ_ACTIONS, "timeout_options"), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "timeout_options": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "activate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "deactivate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } model = IntegrationHeartBeat diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 57566f52..02ca047c 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -6,7 +6,7 @@ from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated from telegram import error -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting @@ -21,7 +21,14 @@ from common.api_helpers.mixins import PublicPrimaryKeyMixin class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): serializer_class = LiveSettingSerializer authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "list": [RBACPermission.Permissions.OTHER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.OTHER_SETTINGS_READ], + "create": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "destroy": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def dispatch(self, request, *args, **kwargs): if not settings.FEATURE_LIVE_SETTINGS_ENABLED: diff --git a/engine/apps/api/views/maintenance.py b/engine/apps/api/views/maintenance.py index aa69dd9b..31fd8cd4 100644 --- a/engine/apps/api/views/maintenance.py +++ b/engine/apps/api/views/maintenance.py @@ -5,7 +5,7 @@ from rest_framework.views import APIView from apps.alerts.models import AlertReceiveChannel from apps.alerts.models.maintainable_object import MaintainableObject -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from common.api_helpers.exceptions import BadRequest from common.exceptions import MaintenanceCouldNotBeStartedError @@ -39,7 +39,11 @@ class GetObjectMixin: class MaintenanceAPIView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.MAINTENANCE_READ], + } def get(self, request): organization = self.request.auth.organization @@ -77,7 +81,10 @@ class MaintenanceAPIView(APIView): class MaintenanceStartAPIView(GetObjectMixin, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.MAINTENANCE_WRITE], + } def post(self, request): mode = request.data.get("mode", None) @@ -110,7 +117,10 @@ class MaintenanceStartAPIView(GetObjectMixin, APIView): class MaintenanceStopAPIView(GetObjectMixin, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.MAINTENANCE_WRITE], + } def post(self, request): instance = self.get_object(request) diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index 548a9df6..b7b6df75 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.on_call_shifts import OnCallShiftSerializer, OnCallShiftUpdateSerializer from apps.auth_token.auth import PluginAuthentication from apps.schedules.models import CustomOnCallShift @@ -18,11 +18,20 @@ from common.insight_log import EntityEvent, write_resource_insight_log class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "preview"), - AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.SCHEDULES_READ], + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "details": [RBACPermission.Permissions.SCHEDULES_READ], + "frequency_options": [RBACPermission.Permissions.SCHEDULES_READ], + "days_options": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "preview": [RBACPermission.Permissions.SCHEDULES_WRITE], } model = CustomOnCallShift diff --git a/engine/apps/api/views/organization.py b/engine/apps/api/views/organization.py index 9545ffb3..bce742b1 100644 --- a/engine/apps/api/views/organization.py +++ b/engine/apps/api/views/organization.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import AnyRole, IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.api.serializers.organization import CurrentOrganizationSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.messaging import get_messaging_backend_from_id @@ -16,9 +16,12 @@ from common.insight_log import EntityEvent, write_resource_insight_log class CurrentOrganizationView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) - method_permissions = {IsAdmin: ("PUT",), AnyRole: ("GET",)} + rbac_permissions = { + "get": [], + "put": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -46,7 +49,11 @@ class CurrentOrganizationView(APIView): class GetTelegramVerificationCode(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -60,13 +67,17 @@ class GetTelegramVerificationCode(APIView): bot_username = telegram_client.api_client.username bot_link = f"https://t.me/{bot_username}" return Response( - {"telegram_code": str(new_code.uuid_with_org_id), "bot_link": bot_link}, status=status.HTTP_200_OK + {"telegram_code": str(new_code.uuid_with_org_uuid), "bot_link": bot_link}, status=status.HTTP_200_OK ) class GetChannelVerificationCode(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -81,7 +92,11 @@ class GetChannelVerificationCode(APIView): class SetGeneralChannel(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "post": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + } def post(self, request): SlackChannel = apps.get_model("slack", "SlackChannel") diff --git a/engine/apps/api/views/public_api_tokens.py b/engine/apps/api/views/public_api_tokens.py index 55833ce7..2bded740 100644 --- a/engine/apps/api/views/public_api_tokens.py +++ b/engine/apps/api/views/public_api_tokens.py @@ -2,7 +2,7 @@ from rest_framework import mixins, status, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.public_api_token import PublicApiTokenSerializer from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import MAX_PUBLIC_API_TOKENS_PER_USER @@ -19,9 +19,14 @@ class PublicApiTokenView( viewsets.GenericViewSet, ): authentication_classes = [PluginAuthentication] - permission_classes = [IsAuthenticated] - - action_permissions = {IsAdmin: (*MODIFY_ACTIONS, *READ_ACTIONS)} + permission_classes = [IsAuthenticated, RBACPermission] + rbac_permissions = { + "metadata": [RBACPermission.Permissions.API_KEYS_READ], + "list": [RBACPermission.Permissions.API_KEYS_READ], + "retrieve": [RBACPermission.Permissions.API_KEYS_READ], + "create": [RBACPermission.Permissions.API_KEYS_WRITE], + "destroy": [RBACPermission.Permissions.API_KEYS_WRITE], + } model = ApiAuthToken serializer_class = PublicApiTokenSerializer diff --git a/engine/apps/api/views/resolution_note.py b/engine/apps/api/views/resolution_note.py index 8400addd..02a77771 100644 --- a/engine/apps/api/views/resolution_note.py +++ b/engine/apps/api/views/resolution_note.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ResolutionNote from apps.alerts.tasks import send_update_resolution_note_signal -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.resolution_note import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin @@ -11,11 +11,16 @@ from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMix class ResolutionNoteView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "partial_update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], } model = ResolutionNote diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 7ecebaf4..8ded3d84 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -1,4 +1,3 @@ -import pytz from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, OuterRef, Subquery from django.db.utils import IntegrityError @@ -14,7 +13,7 @@ from rest_framework.views import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationChain, EscalationPolicy -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.schedule_base import ScheduleFastSerializer from apps.api.serializers.schedule_polymorphic import ( PolymorphicScheduleCreateSerializer, @@ -37,6 +36,7 @@ from common.api_helpers.mixins import ( ) from common.api_helpers.utils import create_engine_url, get_date_range_from_request from common.insight_log import EntityEvent, write_resource_insight_log +from common.timezones import raise_exception_if_not_valid_timezone EVENTS_FILTER_BY_ROTATION = "rotation" EVENTS_FILTER_BY_OVERRIDE = "override" @@ -56,24 +56,26 @@ class ScheduleView( ModelViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: ( - *MODIFY_ACTIONS, - "reload_ical", - ), - IsAdminOrEditor: ("export_token",), - AnyRole: ( - *READ_ACTIONS, - "events", - "filter_events", - "next_shifts_per_user", - "notify_empty_oncall_options", - "notify_oncall_shift_freq_options", - "mention_options", - "related_escalation_chains", - ), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.SCHEDULES_READ], + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "events": [RBACPermission.Permissions.SCHEDULES_READ], + "filter_events": [RBACPermission.Permissions.SCHEDULES_READ], + "next_shifts_per_user": [RBACPermission.Permissions.SCHEDULES_READ], + "notify_empty_oncall_options": [RBACPermission.Permissions.SCHEDULES_READ], + "notify_oncall_shift_freq_options": [RBACPermission.Permissions.SCHEDULES_READ], + "mention_options": [RBACPermission.Permissions.SCHEDULES_READ], + "related_escalation_chains": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "reload_ical": [RBACPermission.Permissions.SCHEDULES_WRITE], + "export_token": [RBACPermission.Permissions.SCHEDULES_EXPORT], } + filter_backends = [SearchFilter] search_fields = ("name",) @@ -204,10 +206,8 @@ class ScheduleView( def get_request_timezone(self): user_tz = self.request.query_params.get("user_tz", "UTC") - try: - pytz.timezone(user_tz) - except pytz.exceptions.UnknownTimeZoneError: - raise BadRequest(detail="Invalid tz format") + raise_exception_if_not_valid_timezone(user_tz) + date = timezone.now().date() date_param = self.request.query_params.get("date") if date_param is not None: diff --git a/engine/apps/api/views/slack_team_settings.py b/engine/apps/api/views/slack_team_settings.py index 0da7525b..e52f250b 100644 --- a/engine/apps/api/views/slack_team_settings.py +++ b/engine/apps/api/views/slack_team_settings.py @@ -2,7 +2,7 @@ from rest_framework import views from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import AnyRole, IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.api.serializers.organization_slack_settings import OrganizationSlackSettingsSerializer from apps.auth_token.auth import PluginAuthentication from apps.user_management.models import Organization @@ -11,11 +11,11 @@ from common.insight_log import EntityEvent, write_resource_insight_log class SlackTeamSettingsAPIView(views.APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) - method_permissions = { - IsAdmin: ("PUT",), - AnyRole: ("GET",), + rbac_permissions = { + "get": [RBACPermission.Permissions.CHATOPS_READ], + "put": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], } serializer_class = OrganizationSlackSettingsSerializer diff --git a/engine/apps/api/views/telegram_channels.py b/engine/apps/api/views/telegram_channels.py index a8d5cdbb..7fd9975d 100644 --- a/engine/apps/api/views/telegram_channels.py +++ b/engine/apps/api/views/telegram_channels.py @@ -4,7 +4,7 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.telegram import TelegramToOrganizationConnectorSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -19,11 +19,14 @@ class TelegramChannelViewSet( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "set_default"), - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.CHATOPS_READ], + "list": [RBACPermission.Permissions.CHATOPS_READ], + "retrieve": [RBACPermission.Permissions.CHATOPS_READ], + "destroy": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + "set_default": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], } serializer_class = TelegramToOrganizationConnectorSerializer diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index de0a8590..f54d58be 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -16,12 +16,10 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.api.permissions import ( - MODIFY_ACTIONS, - READ_ACTIONS, - ActionPermission, - AnyRole, - IsAdminOrEditor, - IsOwnerOrAdmin, + IsOwnerOrHasRBACPermissions, + LegacyAccessControlRole, + RBACPermission, + user_is_authorized, ) from apps.api.serializers.team import TeamSerializer from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer @@ -41,7 +39,6 @@ from common.api_helpers.exceptions import Conflict from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator from common.api_helpers.utils import create_engine_url -from common.constants.role import Role from common.insight_log import ( ChatOpsEvent, ChatOpsType, @@ -51,6 +48,7 @@ from common.insight_log import ( ) logger = logging.getLogger(__name__) +IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions([RBACPermission.Permissions.USER_SETTINGS_ADMIN]) class CurrentUserView(APIView): @@ -89,7 +87,9 @@ class UserFilter(filters.FilterSet): """ email = filters.CharFilter(field_name="email", lookup_expr="icontains") - roles = filters.MultipleChoiceFilter(field_name="role", choices=Role.choices()) + roles = filters.MultipleChoiceFilter( + field_name="role", choices=LegacyAccessControlRole.choices() + ) # LEGACY.. this should get removed eventually class Meta: model = User @@ -109,36 +109,36 @@ class UserView( PluginAuthentication, ) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - # Non-admin users are allowed to list and retrieve users - # The overridden get_serializer_class will return - # another Serializer for non-admin users with sensitive information hidden - action_permissions = { - IsAdminOrEditor: ( - *MODIFY_ACTIONS, - "list", - "metadata", - "verify_number", - "forget_number", - "get_verification_code", - "get_backend_verification_code", - "get_telegram_verification_code", - "unlink_slack", - "unlink_telegram", - "unlink_backend", - "make_test_call", - "export_token", - "mobile_app_verification_token", - "mobile_app_auth_token", - ), - AnyRole: ("retrieve", "timezone_options"), + rbac_permissions = { + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + "timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "list": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "forget_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_backend_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_telegram_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_slack": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_telegram": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "mobile_app_auth_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } - action_object_permissions = { - IsOwnerOrAdmin: ( - *MODIFY_ACTIONS, - *READ_ACTIONS, + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "metadata", + "list", + "retrieve", + "update", + "partial_update", + "destroy", "verify_number", "forget_number", "get_verification_code", @@ -149,9 +149,8 @@ class UserView( "unlink_backend", "make_test_call", "export_token", - "mobile_app_verification_token", "mobile_app_auth_token", - ), + ], } filter_serializer_class = FilterUserSerializer @@ -174,14 +173,18 @@ class UserView( filterset_class = UserFilter def get_serializer_class(self): - is_filters_request = self.request.query_params.get("filters", "false") == "true" + request = self.request + user = request.user + kwargs = self.kwargs + + is_filters_request = request.query_params.get("filters", "false") == "true" if self.action in ["list"] and is_filters_request: return self.get_filter_serializer_class() - is_users_own_data = ( - self.kwargs.get("pk") is not None and self.kwargs.get("pk") == self.request.user.public_primary_key - ) - if is_users_own_data or self.request.user.role == Role.ADMIN: + is_users_own_data = kwargs.get("pk") is not None and kwargs.get("pk") == user.public_primary_key + has_admin_permission = user_is_authorized(user, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) + + if is_users_own_data or has_admin_permission: return UserSerializer return UserHiddenFieldsSerializer @@ -371,7 +374,7 @@ class UserView( bot_link = f"https://t.me/{bot_username}" return Response( - {"telegram_code": str(new_code.uuid_with_org_id), "bot_link": bot_link}, status=status.HTTP_200_OK + {"telegram_code": str(new_code.uuid_with_org_uuid), "bot_link": bot_link}, status=status.HTTP_200_OK ) @action(detail=True, methods=["post"]) @@ -475,6 +478,9 @@ class UserView( authentication_classes=(MobileAppVerificationTokenAuthentication,), ) def mobile_app_auth_token(self, request): + """ + TODO: remove after hackathon app is deprecated (see apps.mobile_app.views.MobileAppAuthTokenAPIView) + """ DynamicSetting = apps.get_model("base", "DynamicSetting") if not settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index c1d7e553..e43622a0 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -6,14 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.api.permissions import ( - MODIFY_ACTIONS, - READ_ACTIONS, - ActionPermission, - AnyRole, - IsAdminOrEditor, - IsOwnerOrAdmin, -) +from apps.api.permissions import IsOwnerOrHasRBACPermissions, RBACPermission from apps.api.serializers.user_notification_policy import ( UserNotificationPolicySerializer, UserNotificationPolicyUpdateSerializer, @@ -31,18 +24,34 @@ from common.insight_log import EntityEvent, write_resource_insight_log class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: (*READ_ACTIONS, "delay_options", "notify_by_options"), - } - action_object_permissions = { - IsOwnerOrAdmin: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.USER_SETTINGS_READ], + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + "delay_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "notify_by_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "create": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "destroy": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "move_to_position": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } - ownership_field = "user" + IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions( + required_permissions=[RBACPermission.Permissions.USER_SETTINGS_ADMIN], ownership_field="user" + ) + + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "create", + "update", + "partial_update", + "destroy", + "move_to_position", + ], + } model = UserNotificationPolicy serializer_class = UserNotificationPolicySerializer diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 47d8ece9..8b0da92a 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -8,11 +8,11 @@ from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request +from apps.api.permissions import RBACPermission, user_is_authorized from apps.grafana_plugin.helpers.gcom import check_token from apps.user_management.models import User from apps.user_management.models.organization import Organization from apps.user_management.models.region import OrganizationMovedException -from common.constants.role import Role from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken @@ -29,7 +29,7 @@ class ApiTokenAuthentication(BaseAuthentication): auth = get_authorization_header(request).decode("utf-8") user, auth_token = self.authenticate_credentials(auth) - if user.role != Role.ADMIN: + if not user_is_authorized(user, [RBACPermission.Permissions.API_KEYS_WRITE]): raise exceptions.AuthenticationFailed( "Only users with Admin permissions are allowed to perform this action." ) diff --git a/engine/apps/base/constants.py b/engine/apps/base/constants.py deleted file mode 100644 index 3e719f8c..00000000 --- a/engine/apps/base/constants.py +++ /dev/null @@ -1,23 +0,0 @@ -# This is temporary solution to not to hardcode permissions on frontend -# Is should be removed with one which will collect permission from action_permission views' attribute -ALL_PERMISSIONS = [ - "update_incidents", - "update_alert_receive_channels", - "update_escalation_policies", - "update_notification_policies", - "update_general_log_channel_id", - "update_own_settings", - "update_other_users_settings", - "update_integrations", - "update_schedules", - "update_custom_actions", - "update_api_tokens", - "update_teams", - "update_maintenances", - "update_global_settings", - "send_demo_alert", - "view_other_users", -] -ADMIN_PERMISSIONS = ALL_PERMISSIONS -EDITOR_PERMISSIONS = ["update_incidents", "update_own_settings", "view_other_users"] -ALL_ROLES_PERMISSIONS = [] diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 3d9e366b..efd8d6e1 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -68,7 +68,7 @@ class UserNotificationPolicyLogRecord(models.Model): ERROR_NOTIFICATION_IN_SLACK_CHANNEL_IS_ARCHIVED, ERROR_NOTIFICATION_IN_SLACK_RATELIMIT, ERROR_NOTIFICATION_MESSAGING_BACKEND_ERROR, - ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + ERROR_NOTIFICATION_FORBIDDEN, ERROR_NOTIFICATION_TELEGRAM_USER_IS_DEACTIVATED, ) = range(27) @@ -258,10 +258,8 @@ class UserNotificationPolicyLogRecord(models.Model): result += f"failed to notify {user_verbal} in Slack, because channel is archived" elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK_RATELIMIT: result += f"failed to notify {user_verbal} in Slack due to Slack rate limit" - elif ( - self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ): - result += f"failed to notify {user_verbal}, not allowed role" + elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN: + result += f"failed to notify {user_verbal}, not allowed" elif ( self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_TELEGRAM_USER_IS_DEACTIVATED diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index c23c5f90..9244a1ce 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -1,7 +1,7 @@ import json import logging import time -from typing import Optional, Tuple +from typing import Dict, List, Optional, Tuple, TypedDict from urllib.parse import urljoin import requests @@ -9,23 +9,51 @@ from django.conf import settings from rest_framework import status from rest_framework.response import Response +from apps.api.permissions import ACTION_PREFIX, GrafanaAPIPermission + logger = logging.getLogger(__name__) +class GrafanaUser(TypedDict): + orgId: int + userId: int + email: str + name: str + avatarUrl: str + login: str + role: str + lastSeenAt: str + lastSeenAtAge: str + + +class GrafanaUserWithPermissions(GrafanaUser): + permissions: List[GrafanaAPIPermission] + + +class GCOMInstanceInfo(TypedDict): + id: int + orgId: int + slug: str + orgSlug: str + orgName: str + url: str + status: str + + class APIClient: def __init__(self, api_url: str, api_token: str): self.api_url = api_url self.api_token = api_token + def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.head, body) + def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]: return self.call_api(endpoint, requests.get) def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: return self.call_api(endpoint, requests.post, body) - def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.head, body) - def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]: request_start = time.perf_counter() call_status = { @@ -72,30 +100,60 @@ class APIClient: class GrafanaAPIClient(APIClient): + USER_PERMISSION_ENDPOINT = f"api/access-control/users/permissions/search?actionPrefix={ACTION_PREFIX}" + def __init__(self, api_url: str, api_token: str): super().__init__(api_url, api_token) def check_token(self) -> Tuple[Optional[Response], dict]: return self.api_head("api/org") - def get_users(self) -> Tuple[Optional[Response], dict]: + def get_users_permissions(self, rbac_is_enabled_for_org: bool) -> Dict[str, List[GrafanaAPIPermission]]: """ - Response example: - [ - { - 'orgId': 1, - 'userId': 1, - 'email': 'user@example.com', - 'name': 'User User', - 'avatarUrl': '/avatar/79163f696e9e08958c0d3f73c160e2cc', - 'login': 'user', - 'role': 'Admin', - 'lastSeenAt': '2021-06-21T07:01:45Z', - 'lastSeenAtAge': '9m' - }, - ] + It is possible that this endpoint may not be available for certain Grafana orgs. + Ex: for Grafana Cloud orgs whom have pinned their Grafana version to an earlier version + where this endpoint is not available + + The response from the Grafana endpoint will look something like this: + { + "1": { + "grafana-oncall-app.alert-groups:read": [ + "" + ], + "grafana-oncall-app.alert-groups:write": [ + "" + ] + } + } """ - return self.api_get("api/org/users") + if not rbac_is_enabled_for_org: + return {} + data, _ = self.api_get(self.USER_PERMISSION_ENDPOINT) + if data is None: + return {} + + all_users_permissions = {} + for user_id, user_permissions in data.items(): + all_users_permissions[user_id] = [GrafanaAPIPermission(action=key) for key, _ in user_permissions.items()] + + return all_users_permissions + + def is_rbac_enabled_for_organization(self) -> bool: + _, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT) + return resp_status["status_code"] == status.HTTP_200_OK + + def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]: + users, _ = self.api_get("api/org/users") + + if not users: + return [] + + user_permissions = self.get_users_permissions(rbac_is_enabled_for_org) + + # merge the users permissions response into the org users response + for user in users: + user["permissions"] = user_permissions.get(str(user["userId"]), []) + return users def get_teams(self): return self.api_get("api/teams/search?perpage=1000000") @@ -127,21 +185,20 @@ class GcomAPIClient(APIClient): ACTIVE_INSTANCE_QUERY = "instances?status=active" DELETED_INSTANCE_QUERY = "instances?status=deleted&includeDeleted=true" STACK_STATUS_DELETED = "deleted" + STACK_STATUS_ACTIVE = "active" def __init__(self, api_token: str): super().__init__(settings.GRAFANA_COM_API_URL, api_token) - def check_token(self): - return self.api_post("api-keys/check", {"token": self.api_token}) - - def get_instance_info(self, stack_id: str): - return self.api_get(f"instances/{stack_id}") + def get_instance_info(self, stack_id: str) -> Optional[GCOMInstanceInfo]: + data, _ = self.api_get(f"instances/{stack_id}?config=true") + return data def get_instances(self, query: str): return self.api_get(query) def is_stack_deleted(self, stack_id: str) -> bool: - instance_info, call_status = self.get_instance_info(stack_id) + instance_info = self.get_instance_info(stack_id) return instance_info and instance_info.get("status") == self.STACK_STATUS_DELETED def post_active_users(self, body): diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index 407f70e5..f4b5b47b 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -40,10 +40,12 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: logger.debug(f"Start authenticate by making request to gcom api for org={org_id}, stack_id={stack_id}") client = GcomAPIClient(token_string) - instance_info, status = client.get_instance_info(stack_id) + instance_info = client.get_instance_info(stack_id) if not instance_info or str(instance_info["orgId"]) != org_id: raise InvalidToken + rbac_is_enabled = client.is_rbac_enabled_for_organization() + if not organization: DynamicSetting = apps.get_model("base", "DynamicSetting") allow_signup = DynamicSetting.objects.get_or_create( @@ -60,6 +62,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: region_slug=instance_info["regionSlug"], gcom_token=token_string, gcom_token_org_last_time_synced=timezone.now(), + is_rbac_permissions_enabled=rbac_is_enabled, ) else: organization.stack_slug = instance_info["slug"] @@ -69,6 +72,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: organization.grafana_url = instance_info["url"] organization.gcom_token = token_string organization.gcom_token_org_last_time_synced = timezone.now() + organization.is_rbac_permissions_enabled = rbac_is_enabled organization.save( update_fields=[ "stack_slug", @@ -78,6 +82,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: "grafana_url", "gcom_token", "gcom_token_org_last_time_synced", + "is_rbac_permissions_enabled", ] ) logger.debug(f"Finish authenticate by making request to gcom api for org={org_id}, stack_id={stack_id}") diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index ed58968d..204797d9 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -68,8 +68,8 @@ def run_organization_sync(organization_pk, force_sync): return if settings.GRAFANA_COM_API_TOKEN and settings.LICENSE == settings.CLOUD_LICENSE_NAME: client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN) - instance_info, status = client.get_instance_info(organization.stack_id) - if not instance_info or instance_info["status"] != "active": + instance_info = client.get_instance_info(organization.stack_id) + if not instance_info or instance_info["status"] != client.STACK_STATUS_ACTIVE: logger.debug(f"Canceling sync for Organization {organization_pk}, as it is no longer active.") return diff --git a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py new file mode 100644 index 00000000..ef4af42e --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +import pytest +from rest_framework import status + +from apps.grafana_plugin.helpers.client import GrafanaAPIClient + +API_URL = "/foo/bar" +API_TOKEN = "dfjkfdjkfd" + + +class TestGetUsersPermissions: + def test_rbac_is_not_enabled_for_org(self): + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + permissions = api_client.get_users_permissions(False) + assert len(permissions.keys()) == 0 + + @patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_get") + def test_api_call_returns_none(self, mocked_grafana_api_client_api_get): + mocked_grafana_api_client_api_get.return_value = (None, "dfkjfdkj") + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + + permissions = api_client.get_users_permissions(True) + assert len(permissions.keys()) == 0 + + @patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_get") + def test_it_properly_transforms_the_data(self, mocked_grafana_api_client_api_get): + mocked_grafana_api_client_api_get.return_value = ( + {"1": {"grafana-oncall-app.alert-groups:read": [""], "grafana-oncall-app.alert-groups:write": [""]}}, + "asdfasdf", + ) + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + + permissions = api_client.get_users_permissions(True) + assert permissions == { + "1": [ + {"action": "grafana-oncall-app.alert-groups:read"}, + {"action": "grafana-oncall-app.alert-groups:write"}, + ] + } + + +class TestIsRbacEnabledForOrganization: + @pytest.mark.parametrize( + "grafana_api_status_code,expected", + [ + (status.HTTP_200_OK, True), + (status.HTTP_404_NOT_FOUND, False), + ], + ) + @patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_head") + def test_it_returns_based_on_status_code_of_head_call( + self, mocked_grafana_api_client_api_head, grafana_api_status_code, expected + ): + mocked_grafana_api_client_api_head.return_value = (None, {"status_code": grafana_api_status_code}) + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + assert api_client.is_rbac_enabled_for_organization() == expected diff --git a/engine/apps/grafana_plugin/tests/test_self_hosted_install.py b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py index 62d835d5..244d8d4d 100644 --- a/engine/apps/grafana_plugin/tests/test_self_hosted_install.py +++ b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py @@ -99,6 +99,7 @@ def test_if_organization_exists_it_is_updated( mocked_provision_plugin.return_value = provision_plugin_response mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.return_value = True client = APIClient() url = reverse("grafana-plugin:self-hosted-install") @@ -106,6 +107,8 @@ def test_if_organization_exists_it_is_updated( assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.called_once_with() + assert mocked_sync_organization.called_once_with(organization) assert mocked_provision_plugin.called_once_with() assert mocked_revoke_plugin.called_once_with() @@ -117,6 +120,7 @@ def test_if_organization_exists_it_is_updated( assert organization.grafana_url == GRAFANA_API_URL assert organization.api_token == GRAFANA_TOKEN + assert organization.is_rbac_permissions_enabled is True @override_settings(SELF_HOSTED_SETTINGS=SELF_HOSTED_SETTINGS) @@ -136,6 +140,7 @@ def test_if_organization_does_not_exist_it_is_created( mocked_provision_plugin.return_value = provision_plugin_response mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.return_value = True client = APIClient() url = reverse("grafana-plugin:self-hosted-install") @@ -146,6 +151,8 @@ def test_if_organization_does_not_exist_it_is_created( assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.called_once_with() + assert mocked_sync_organization.called_once_with(organization) assert mocked_provision_plugin.called_once_with() assert not mocked_revoke_plugin.called @@ -160,3 +167,4 @@ def test_if_organization_does_not_exist_it_is_created( assert organization.region_slug == REGION_SLUG assert organization.grafana_url == GRAFANA_API_URL assert organization.api_token == GRAFANA_TOKEN + assert organization.is_rbac_permissions_enabled is True diff --git a/engine/apps/grafana_plugin/tests/test_sync.py b/engine/apps/grafana_plugin/tests/test_sync.py index f37b2e41..7cf8f11f 100644 --- a/engine/apps/grafana_plugin/tests/test_sync.py +++ b/engine/apps/grafana_plugin/tests/test_sync.py @@ -26,6 +26,8 @@ class TestGcomAPIClient: info = None status = None + STACK_STATUS_ACTIVE = "active" + def reset(self): self.called = False self.info = None @@ -39,7 +41,7 @@ class TestGcomAPIClient: def get_instance_info(self, stack_id: str): self.called = True - return self.info, self.status + return self.info @pytest.mark.django_db diff --git a/engine/apps/grafana_plugin/views/self_hosted_install.py b/engine/apps/grafana_plugin/views/self_hosted_install.py index 7117cf63..8a40fed0 100644 --- a/engine/apps/grafana_plugin/views/self_hosted_install.py +++ b/engine/apps/grafana_plugin/views/self_hosted_install.py @@ -43,11 +43,14 @@ class SelfHostedInstallView(GrafanaHeadersMixin, APIView): return Response(data=provisioning_info, status=status.HTTP_400_BAD_REQUEST) organization = Organization.objects.filter(stack_id=stack_id, org_id=org_id).first() + rbac_is_enabled = grafana_api_client.is_rbac_enabled_for_organization() + if organization: organization.revoke_plugin() organization.grafana_url = grafana_url organization.api_token = grafana_api_token - organization.save(update_fields=["grafana_url", "api_token"]) + organization.is_rbac_permissions_enabled = rbac_is_enabled + organization.save(update_fields=["grafana_url", "api_token", "is_rbac_permissions_enabled"]) else: organization = Organization.objects.create( stack_id=stack_id, @@ -58,6 +61,7 @@ class SelfHostedInstallView(GrafanaHeadersMixin, APIView): region_slug=settings.SELF_HOSTED_SETTINGS["REGION_SLUG"], grafana_url=grafana_url, api_token=grafana_api_token, + is_rbac_permissions_enabled=rbac_is_enabled, ) sync_organization(organization) diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py index 82187250..40663216 100644 --- a/engine/apps/mobile_app/backend.py +++ b/engine/apps/mobile_app/backend.py @@ -1,4 +1,7 @@ -from push_notifications.models import APNSDevice +import json + +from django.conf import settings +from push_notifications.models import APNSDevice, GCMDevice from apps.base.messaging import BaseMessagingBackend from apps.mobile_app.tasks import notify_user_async @@ -11,7 +14,6 @@ class MobileAppBackend(BaseMessagingBackend): available_for_use = True template_fields = ["title"] - # TODO: add QR code generation (base64 encode?) def generate_user_verification_code(self, user): from apps.mobile_app.models import MobileAppVerificationToken @@ -19,7 +21,12 @@ class MobileAppBackend(BaseMessagingBackend): MobileAppVerificationToken.objects.filter(user=user).delete() _, token = MobileAppVerificationToken.create_auth_token(user, user.organization) - return token + return json.dumps( + { + "token": token, + "oncall_api_url": settings.BASE_URL, + } + ) def unlink_user(self, user): from apps.mobile_app.models import MobileAppAuthToken @@ -27,9 +34,14 @@ class MobileAppBackend(BaseMessagingBackend): token = MobileAppAuthToken.objects.get(user=user) token.delete() + # delete push notification related info for user + APNSDevice.objects.filter(user=user).delete() + GCMDevice.objects.filter(user=user).delete() + def serialize_user(self, user): - # TODO: add Android support using GCMDevice - return {"connected": APNSDevice.objects.filter(user_id=user.pk).exists()} + from apps.mobile_app.models import MobileAppAuthToken + + return {"connected": MobileAppAuthToken.objects.filter(user=user).exists()} def notify_user(self, user, alert_group, notification_policy, critical=False): notify_user_async.delay( diff --git a/engine/apps/mobile_app/fcm_relay.py b/engine/apps/mobile_app/fcm_relay.py new file mode 100644 index 00000000..33bebda4 --- /dev/null +++ b/engine/apps/mobile_app/fcm_relay.py @@ -0,0 +1,26 @@ +from push_notifications.gcm import send_message +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +REQUIRED_FIELDS = {"registration_ids", "notification", "data"} + + +class FCMRelayView(APIView): + def post(self, request): + """ + This view accepts requests from OSS instances of Grafana OnCall and forwards these requests to FCM. + Requests will be sent with the FCM_API_KEY configured in server settings + (see PUSH_NOTIFICATIONS_SETTINGS in settings/base.py) + """ + + if not REQUIRED_FIELDS.issubset(request.data.keys()): + return Response(status=status.HTTP_400_BAD_REQUEST) + + registration_ids = request.data["registration_ids"] + data = { + **request.data["data"], + **request.data["notification"], + } + + return send_message(registration_ids=registration_ids, data=data, cloud_type="FCM") diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 059503fb..5af825a9 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,9 +1,21 @@ -from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet -from common.api_helpers.optional_slash_router import OptionalSlashRouter +from django.conf import settings +from apps.mobile_app.fcm_relay import FCMRelayView +from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path + +app_name = "mobile_app" router = OptionalSlashRouter() -router.register("apns", APNSDeviceAuthorizedViewSet) -router.register("gcm", GCMDeviceAuthorizedViewSet) +router.register("apns", APNSDeviceAuthorizedViewSet, basename="apns") +router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm") -urlpatterns = router.urls +urlpatterns = [ + *router.urls, + optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), +] + +if settings.FCM_RELAY_ENABLED: + urlpatterns += [ + optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"), + ] diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 9755b74d..a9290f85 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -1,12 +1,67 @@ from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet as BaseAPNSDeviceAuthorizedViewSet -from push_notifications.api.rest_framework import GCMDeviceAuthorizedViewSet as BaseGCMDeviceAuthorizedViewSet +from push_notifications.api.rest_framework import GCMDeviceAuthorizedViewSet, GCMDeviceSerializer +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.serializers import HiddenField +from rest_framework.views import APIView -from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication +from apps.mobile_app.models import MobileAppAuthToken class APNSDeviceAuthorizedViewSet(BaseAPNSDeviceAuthorizedViewSet): authentication_classes = (MobileAppAuthTokenAuthentication,) -class GCMDeviceAuthorizedViewSet(BaseGCMDeviceAuthorizedViewSet): +class FCMDeviceAuthorizedViewSet(GCMDeviceAuthorizedViewSet): + class FCMDeviceSerializer(GCMDeviceSerializer): + """ + GCMDevice has cloud_message_type equal to "GCM" by default, in this serializer cloud_message_type is always set + to "FCM" no matter what was provided in the request. + """ + + cloud_message_type = HiddenField(default="FCM") + authentication_classes = (MobileAppAuthTokenAuthentication,) + serializer_class = FCMDeviceSerializer + + +class MobileAppAuthTokenAPIView(APIView): + authentication_classes = (MobileAppVerificationTokenAuthentication,) + + def get(self, request): + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + except MobileAppAuthToken.DoesNotExist: + raise NotFound + + response = { + "token_id": token.id, + "user_id": token.user_id, + "organization_id": token.organization_id, + "created_at": token.created_at, + "revoked_at": token.revoked_at, + } + return Response(response, status=status.HTTP_200_OK) + + def post(self, request): + # If token already exists revoke it + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + token.delete() + except MobileAppAuthToken.DoesNotExist: + pass + + instance, token = MobileAppAuthToken.create_auth_token(self.request.user, self.request.user.organization) + data = {"id": instance.pk, "token": token, "created_at": instance.created_at} + return Response(data, status=status.HTTP_201_CREATED) + + def delete(self, request): + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + token.delete() + except MobileAppAuthToken.DoesNotExist: + raise NotFound + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 07eb6724..3a828a51 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -8,7 +8,6 @@ from apps.base.utils import live_settings from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User from common.api_helpers.utils import create_engine_url -from common.constants.role import Role from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) @@ -61,7 +60,7 @@ class CloudConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.filter(role__in=(Role.ADMIN, Role.EDITOR)).values_list("email", flat=True)) + existing_emails = [user.email for user in User.objects.all() if user.is_notification_allowed] matching_users = [] users_url = create_engine_url("api/v1/users", override_base=GRAFANA_CLOUD_ONCALL_API_URL) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 21b6624c..de73343c 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings @@ -13,7 +13,11 @@ from apps.oss_installation.models import CloudConnector, CloudHeartbeat class CloudConnectionView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "get": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "delete": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def get(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/oss_installation/views/cloud_heartbeat.py b/engine/apps/oss_installation/views/cloud_heartbeat.py index 932087c3..a3a2973e 100644 --- a/engine/apps/oss_installation/views/cloud_heartbeat.py +++ b/engine/apps/oss_installation/views/cloud_heartbeat.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.cloud_heartbeat import get_heartbeat_link, setup_heartbeat_integration from apps.oss_installation.models import CloudConnector, CloudHeartbeat @@ -11,7 +11,10 @@ from apps.oss_installation.models import CloudConnector, CloudHeartbeat class CloudHeartbeatView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def post(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 3eb7685b..75d3886a 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin +from apps.api.permissions import IsOwnerOrHasRBACPermissions, RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer @@ -14,17 +14,26 @@ from apps.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator -from common.constants.role import Role + +PERMISSIONS = [RBACPermission.Permissions.OTHER_SETTINGS_WRITE] class CloudUsersView(HundredPageSizePaginator, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": PERMISSIONS, + "post": PERMISSIONS, + } def get(self, request): organization = request.user.organization - queryset = User.objects.filter(organization=organization, role__in=[Role.ADMIN, Role.EDITOR]) + queryset = User.objects.filter( + organization=organization, + **User.build_permissions_query(RBACPermission.Permissions.NOTIFICATIONS_READ, organization), + ) if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() @@ -81,15 +90,24 @@ class CloudUserView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - AnyRole: ("retrieve",), - IsAdmin: ("sync",), + rbac_permissions = { + "retrieve": PERMISSIONS, + "sync": PERMISSIONS, } - action_object_permissions = { - IsOwnerOrAdmin: ("retrieve", "sync"), + + IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions( + [RBACPermission.Permissions.USER_SETTINGS_ADMIN] + ) + + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "retrieve", + "sync", + ], } + serializer_class = CloudUserSerializer def get_queryset(self): diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 15b0d271..294cbfd2 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -12,6 +12,7 @@ from common.api_helpers.custom_fields import ( from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin from common.api_helpers.utils import CurrentOrganizationDefault +from common.timezones import TimeZoneField class CustomOnCallShiftTypeField(fields.CharField): @@ -70,7 +71,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer organization = serializers.HiddenField(default=CurrentOrganizationDefault()) team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") type = CustomOnCallShiftTypeField() - time_zone = serializers.CharField(required=False, allow_null=True) + time_zone = TimeZoneField(required=False, allow_null=True) users = UsersFilteredByOrganizationField(queryset=User.objects, required=False) frequency = CustomOnCallShiftFrequencyField(required=False, allow_null=True) week_start = CustomOnCallShiftWeekStartField(required=False) diff --git a/engine/apps/public_api/serializers/schedules_calendar.py b/engine/apps/public_api/serializers/schedules_calendar.py index 04f8bf50..dbbf221c 100644 --- a/engine/apps/public_api/serializers/schedules_calendar.py +++ b/engine/apps/public_api/serializers/schedules_calendar.py @@ -1,7 +1,3 @@ -import pytz -from django.utils import timezone -from rest_framework import serializers - from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar from apps.schedules.tasks import ( @@ -11,10 +7,11 @@ from apps.schedules.tasks import ( ) from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField from common.api_helpers.exceptions import BadRequest +from common.timezones import TimeZoneField class ScheduleCalendarSerializer(ScheduleBaseSerializer): - time_zone = serializers.CharField(required=True) + time_zone = TimeZoneField(required=True) shifts = UsersFilteredByOrganizationField( queryset=CustomOnCallShift.objects, required=False, @@ -37,13 +34,6 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer): "ical_url_overrides": {"required": False, "allow_null": True}, } - def validate_time_zone(self, tz): - try: - timezone.now().astimezone(pytz.timezone(tz)) - except pytz.exceptions.UnknownTimeZoneError: - raise BadRequest(detail="Invalid time zone") - return tz - def validate_shifts(self, shifts): # Get team_id from instance, if it exists, otherwise get it from initial data. if self.instance and self.instance.team: @@ -69,7 +59,7 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer): class ScheduleCalendarUpdateSerializer(ScheduleCalendarSerializer): - time_zone = serializers.CharField(required=False) + time_zone = TimeZoneField(required=False) team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") class Meta: diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py index 4d9c6b84..a4a737c4 100644 --- a/engine/apps/public_api/serializers/schedules_web.py +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -1,6 +1,3 @@ -import pytz -from rest_framework import serializers - from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.schedules.tasks import ( @@ -10,10 +7,11 @@ from apps.schedules.tasks import ( ) from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField from common.api_helpers.exceptions import BadRequest +from common.timezones import TimeZoneField class ScheduleWebSerializer(ScheduleBaseSerializer): - time_zone = serializers.CharField(required=True) + time_zone = TimeZoneField(required=True) shifts = UsersFilteredByOrganizationField( queryset=CustomOnCallShift.objects, required=False, @@ -32,13 +30,6 @@ class ScheduleWebSerializer(ScheduleBaseSerializer): "shifts", ] - def validate_time_zone(self, tz): - try: - pytz.timezone(tz) - except pytz.exceptions.UnknownTimeZoneError: - raise BadRequest(detail="Invalid time zone") - return tz - def validate_shifts(self, shifts): # Get team_id from instance, if it exists, otherwise get it from initial data. # Handle empty string instead of None. In this case change team_id value to None. @@ -57,7 +48,7 @@ class ScheduleWebSerializer(ScheduleBaseSerializer): class ScheduleWebUpdateSerializer(ScheduleWebSerializer): - time_zone = serializers.CharField(required=False) + time_zone = TimeZoneField(required=False) team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") class Meta: diff --git a/engine/apps/public_api/serializers/users.py b/engine/apps/public_api/serializers/users.py index 8afdedaf..6b0f8f26 100644 --- a/engine/apps/public_api/serializers/users.py +++ b/engine/apps/public_api/serializers/users.py @@ -1,9 +1,9 @@ from rest_framework import serializers +from apps.api.permissions import LegacyAccessControlRole from apps.slack.models import SlackUserIdentity from apps.user_management.models import User from common.api_helpers.mixins import EagerLoadingMixin -from common.constants.role import Role class SlackUserIdentitySerializer(serializers.ModelSerializer): @@ -21,7 +21,7 @@ class SlackUserIdentitySerializer(serializers.ModelSerializer): class FastUserSerializer(serializers.ModelSerializer): id = serializers.ReadOnlyField(read_only=True, source="public_primary_key") email = serializers.EmailField(read_only=True) - role = serializers.SerializerMethodField() + role = serializers.SerializerMethodField() # LEGACY, should be removed eventually is_phone_number_verified = serializers.SerializerMethodField() class Meta: @@ -30,7 +30,10 @@ class FastUserSerializer(serializers.ModelSerializer): @staticmethod def get_role(obj): - return Role(obj.role).name.lower() + """ + LEGACY, should be removed eventually + """ + return LegacyAccessControlRole(obj.role).name.lower() def get_is_phone_number_verified(self, obj): return obj.verified_phone_number is not None @@ -39,8 +42,8 @@ class FastUserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin): id = serializers.ReadOnlyField(read_only=True, source="public_primary_key") email = serializers.EmailField(read_only=True) - role = serializers.SerializerMethodField() slack = SlackUserIdentitySerializer(read_only=True, source="slack_user_identity") + role = serializers.SerializerMethodField() # LEGACY, should be removed eventually is_phone_number_verified = serializers.SerializerMethodField() SELECT_RELATED = [ @@ -54,7 +57,10 @@ class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin): @staticmethod def get_role(obj): - return Role(obj.role).name.lower() + """ + LEGACY, should be removed eventually + """ + return LegacyAccessControlRole(obj.role).name.lower() def get_is_phone_number_verified(self, obj): return obj.verified_phone_number is not None diff --git a/engine/apps/public_api/tests/test_incidents.py b/engine/apps/public_api/tests/test_incidents.py index 45a5d115..041bd3e5 100644 --- a/engine/apps/public_api/tests/test_incidents.py +++ b/engine/apps/public_api/tests/test_incidents.py @@ -42,6 +42,7 @@ def construct_expected_response_from_incidents(incidents): "permalinks": { "slack": None, "telegram": None, + "web": incident.web_link, }, } ) diff --git a/engine/apps/public_api/tests/test_on_call_shifts.py b/engine/apps/public_api/tests/test_on_call_shifts.py index 82dac03d..d7dc5986 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -45,6 +45,10 @@ invalid_field_data_9 = { "interval": 5, } +invalid_field_data_10 = { + "time_zone": "asdfasdfasdf", +} + @pytest.mark.django_db def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): @@ -124,8 +128,7 @@ def test_get_override_on_call_shift(make_organization_and_user_with_token, make_ @pytest.mark.django_db def test_create_on_call_shift(make_organization_and_user_with_token): - - organization, user, token = make_organization_and_user_with_token() + _, user, token = make_organization_and_user_with_token() client = APIClient() url = reverse("api-public:on_call_shifts-list") @@ -177,8 +180,7 @@ def test_create_on_call_shift(make_organization_and_user_with_token): @pytest.mark.django_db def test_create_override_on_call_shift(make_organization_and_user_with_token): - - organization, user, token = make_organization_and_user_with_token() + _, user, token = make_organization_and_user_with_token() client = APIClient() url = reverse("api-public:on_call_shifts-list") @@ -213,6 +215,38 @@ def test_create_override_on_call_shift(make_organization_and_user_with_token): assert response.data == result +@pytest.mark.django_db +def test_create_on_call_shift_invalid_time_zone(make_organization_and_user_with_token): + _, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:on_call_shifts-list") + + start = datetime.datetime.now() + until = start + datetime.timedelta(days=30) + data = { + "team_id": None, + "name": "test name", + "type": "recurrent_event", + "level": 1, + "start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "rotation_start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": 10800, + "users": [user.public_primary_key], + "week_start": "MO", + "frequency": "weekly", + "interval": 2, + "until": until.strftime("%Y-%m-%dT%H:%M:%S"), + "by_day": ["MO", "WE", "FR"], + "time_zone": "asdfasdfasdf", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"time_zone": ["Invalid timezone"]} + + @pytest.mark.django_db def test_update_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): organization, user, token = make_organization_and_user_with_token() @@ -290,10 +324,11 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal invalid_field_data_7, invalid_field_data_8, invalid_field_data_9, + invalid_field_data_10, ], ) def test_update_on_call_shift_invalid_field(make_organization_and_user_with_token, make_on_call_shift, data_to_update): - organization, user, token = make_organization_and_user_with_token() + organization, _, token = make_organization_and_user_with_token() client = APIClient() start_date = timezone.datetime.now().replace(microsecond=0) @@ -319,8 +354,7 @@ def test_update_on_call_shift_invalid_field(make_organization_and_user_with_toke @pytest.mark.django_db def test_delete_on_call_shift(make_organization_and_user_with_token, make_on_call_shift): - - organization, user, token = make_organization_and_user_with_token() + organization, _, token = make_organization_and_user_with_token() client = APIClient() start_date = timezone.datetime.now().replace(microsecond=0) diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index bfcba8f0..97ad6399 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -234,8 +234,7 @@ def test_get_web_schedule( @pytest.mark.django_db def test_create_web_schedule(make_organization_and_user_with_token): - - organization, user, token = make_organization_and_user_with_token() + _, _, token = make_organization_and_user_with_token() client = APIClient() url = reverse("api-public:schedules-list") @@ -391,8 +390,7 @@ def test_update_calendar_schedule_invalid_override( make_schedule, make_on_call_shift, ): - - organization, user, token = make_organization_and_user_with_token() + organization, _, token = make_organization_and_user_with_token() client = APIClient() schedule = make_schedule( @@ -418,6 +416,29 @@ def test_update_calendar_schedule_invalid_override( assert response.json() == {"detail": "Shifts of type override are not supported in this schedule"} +@pytest.mark.django_db +@pytest.mark.parametrize("ScheduleClass", [OnCallScheduleWeb, OnCallScheduleCalendar]) +def test_update_schedule_invalid_timezone(make_organization_and_user_with_token, make_schedule, ScheduleClass): + organization, _, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule(organization, schedule_class=ScheduleClass) + start_date = timezone.datetime.now().replace(microsecond=0) + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(seconds=10800), + } + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = {"time_zone": "asdfasdf"} + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"time_zone": ["Invalid timezone"]} + + @pytest.mark.django_db def test_update_web_schedule_with_override( make_organization_and_user_with_token, @@ -694,8 +715,7 @@ def test_get_schedule_list( @pytest.mark.django_db def test_create_schedule_wrong_type(make_organization_and_user_with_token): - - organization, user, token = make_organization_and_user_with_token() + _, _, token = make_organization_and_user_with_token() client = APIClient() url = reverse("api-public:schedules-list") @@ -716,9 +736,29 @@ def test_create_schedule_wrong_type(make_organization_and_user_with_token): @pytest.mark.django_db -def test_create_ical_schedule_without_ical_url(make_organization_and_user_with_token): +@pytest.mark.parametrize("schedule_type", ["web", "calendar"]) +def test_create_schedule_invalid_timezone(make_organization_and_user_with_token, schedule_type): + _, _, token = make_organization_and_user_with_token() + client = APIClient() - organization, user, token = make_organization_and_user_with_token() + url = reverse("api-public:schedules-list") + + print(schedule_type) + data = { + "team_id": None, + "name": "schedule test name", + "time_zone": "asdfasdasdf", + "type": schedule_type, + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"time_zone": ["Invalid timezone"]} + + +@pytest.mark.django_db +def test_create_ical_schedule_without_ical_url(make_organization_and_user_with_token): + _, _, token = make_organization_and_user_with_token() client = APIClient() url = reverse("api-public:schedules-list") diff --git a/engine/apps/public_api/tests/test_users.py b/engine/apps/public_api/tests/test_users.py index 892fbc38..0a0cf612 100644 --- a/engine/apps/public_api/tests/test_users.py +++ b/engine/apps/public_api/tests/test_users.py @@ -3,7 +3,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -20,7 +20,7 @@ def user_public_api_setup( def test_get_user( user_public_api_setup, ): - organization, user, token, slack_team_identity, slack_user_identity = user_public_api_setup + _, user, token, slack_team_identity, slack_user_identity = user_public_api_setup client = APIClient() @@ -93,7 +93,7 @@ def test_get_users_list_short( user_public_api_setup, make_user_for_organization, ): - organization, user_1, token, slack_team_identity, slack_user_identity = user_public_api_setup + organization, user_1, token, _, _ = user_public_api_setup user_2 = make_user_for_organization(organization) client = APIClient() @@ -145,13 +145,10 @@ def test_forbidden_access( @pytest.mark.django_db -def test_get_users_list_all_role_users( - user_public_api_setup, - make_user_for_organization, -): +def test_get_users_list_all_role_users(user_public_api_setup, make_user_for_organization): organization, admin, token, _, _ = user_public_api_setup - editor = make_user_for_organization(organization, role=Role.EDITOR) - viewer = make_user_for_organization(organization, role=Role.VIEWER) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 84851042..1eed6e1d 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -5,6 +5,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.views import Response from rest_framework.viewsets import ReadOnlyModelViewSet +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer @@ -14,7 +15,6 @@ from apps.schedules.models import OnCallSchedule from apps.user_management.models import User from common.api_helpers.mixins import RateLimitHeadersMixin, ShortSerializerMixin from common.api_helpers.paginators import HundredPageSizePaginator -from common.constants.role import Role class UserFilter(filters.FilterSet): @@ -23,7 +23,9 @@ class UserFilter(filters.FilterSet): """ email = filters.CharFilter(field_name="email", lookup_expr="iexact") - roles = filters.MultipleChoiceFilter(field_name="role", choices=Role.choices()) + roles = filters.MultipleChoiceFilter( + field_name="role", choices=LegacyAccessControlRole.choices() + ) # LEGACY, should be removed eventually username = filters.CharFilter(field_name="username", lookup_expr="iexact") class Meta: diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index a49a8b31..7101d437 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -80,7 +80,8 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar): continue repetitions = self.RepeatedEvent(event, span_start) for repetition in repetitions: - if compare_greater(repetition.start, span_stop): + if compare_greater(repetition.start, span_stop) or compare_greater(repetition.start, repetition.stop): + # future repetitions could produce invalid events (because of the until rrule) break if repetition.is_in_span(span_start, span_stop): add_event(repetition.as_vevent()) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 3e6756cd..962766d5 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -13,6 +13,7 @@ from django.db.models import Q from django.utils import timezone from icalendar import Calendar +from apps.api.permissions import RBACPermission from apps.schedules.constants import ( ICAL_ATTENDEE, ICAL_DATETIME_END, @@ -25,7 +26,7 @@ from apps.schedules.constants import ( RE_PRIORITY, ) from apps.schedules.ical_events import ical_events -from common.constants.role import Role +from common.timezones import is_valid_timezone from common.utils import timed_lru_cache """ @@ -40,11 +41,16 @@ if TYPE_CHECKING: def users_in_ical(usernames_from_ical, organization, include_viewers=False): """ Parse ical file and return list of users found + NOTE: only grafana username will be used, consider adding grafana email and id """ - # Only grafana username will be used, consider adding grafana email and id + from apps.user_management.models import User + users_found_in_ical = organization.users if not include_viewers: - users_found_in_ical = users_found_in_ical.filter(role__in=(Role.ADMIN, Role.EDITOR)) + # TODO: this is a breaking change.... + users_found_in_ical = users_found_in_ical.filter( + **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) + ) user_emails = [v.lower() for v in usernames_from_ical] users_found_in_ical = users_found_in_ical.filter( @@ -487,14 +493,14 @@ def get_icalendar_tz_or_utc(icalendar): except (IndexError, KeyError): calendar_timezone = "UTC" - try: - return pytz.timezone(calendar_timezone) - except pytz.UnknownTimeZoneError: - # try to convert the timezone from windows to iana - converted_timezone = convert_windows_timezone_to_iana(calendar_timezone) - if converted_timezone is None: - return "UTC" - return pytz.timezone(converted_timezone) + if pytz_timezone := is_valid_timezone(calendar_timezone): + return pytz_timezone + + # try to convert the timezone from windows to iana + if (converted_timezone := convert_windows_timezone_to_iana(calendar_timezone)) is None: + return "UTC" + + return pytz.timezone(converted_timezone) def fetch_ical_file_or_get_error(ical_url): diff --git a/engine/apps/schedules/migrations/0008_auto_20221201_0809.py b/engine/apps/schedules/migrations/0008_auto_20221201_0809.py new file mode 100644 index 00000000..0aee5c7e --- /dev/null +++ b/engine/apps/schedules/migrations/0008_auto_20221201_0809.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.16 on 2022-12-01 08:09 + +import pytz +from django.db import migrations +from django.db.models import Q + +from common.timezones import is_valid_timezone + + +def fix_bad_timezone_values(model): + def _fix_bad_timezone_values(apps, _schema_editor): + """ + https://docs.djangoproject.com/en/4.1/topics/migrations/#data-migrations + + We can't import the model directly as it may be a newer + version than this migration expects. We use the historical version. + """ + Model = apps.get_model('schedules', model) + objects_to_update = [] + for obj in Model.objects.filter(Q(time_zone__isnull=False) & ~Q(time_zone__in=pytz.all_timezones)): + if not is_valid_timezone(obj.time_zone): + obj.time_zone = pytz.UTC + objects_to_update.append(obj) + + Model.objects.bulk_update(objects_to_update, ['time_zone']) + + return _fix_bad_timezone_values + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0007_customoncallshift_updated_shift'), + ] + + operations = [ + migrations.RunPython(fix_bad_timezone_values('CustomOnCallShift')), + migrations.RunPython(fix_bad_timezone_values('OnCallScheduleCalendar')), + migrations.RunPython(fix_bad_timezone_values('OnCallScheduleWeb')), + ] diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 27d26ceb..96cc2db2 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -373,8 +373,6 @@ class CustomOnCallShift(models.Model): expected_start_day = min(CustomOnCallShift.ICAL_WEEKDAY_REVERSE_MAP[d] for d in self.by_day) delta = (expected_start_day - start.weekday()) % 7 start = start + timezone.timedelta(days=delta) - if self.until is not None: - self.until = self.until + timezone.timedelta(days=delta) if self.frequency == CustomOnCallShift.FREQUENCY_DAILY and self.by_day: result = self._daily_by_day_to_ical(time_zone, start, users_queue) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 7b04d6ae..d746e81c 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -8,6 +8,7 @@ from django.apps import apps from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models +from django.db.utils import DatabaseError from django.utils import timezone from django.utils.functional import cached_property from icalendar.cal import Calendar @@ -318,6 +319,7 @@ class OnCallSchedule(PolymorphicModel): resolved = [] pending = events current_interval_idx = 0 # current scheduled interval being checked + current_type = OnCallSchedule.TYPE_ICAL_OVERRIDES # current calendar type current_priority = None # current priority level being resolved while pending: @@ -327,20 +329,17 @@ class OnCallSchedule(PolymorphicModel): # exclude events without active users continue - if ev["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES: - # include overrides from start - resolved.append(ev) - continue - # api/terraform shifts could be missing a priority; assume None means 0 priority = ev["priority_level"] or 0 - if priority != current_priority: + if priority != current_priority or current_type != ev["calendar_type"]: # update scheduled intervals on priority change # and start from the beginning for the new priority level + # also for calendar event type (overrides first, then apply regular shifts) resolved.sort(key=event_start_cmp_key) intervals = _merge_intervals(resolved) current_interval_idx = 0 current_priority = priority + current_type = ev["calendar_type"] if current_interval_idx >= len(intervals): # event outside scheduled intervals, add to resolved @@ -637,7 +636,11 @@ class OnCallScheduleWeb(OnCallSchedule): """Return cached ical file with iCal events from custom on-call shifts.""" if self.cached_ical_file_primary is None: self.cached_ical_file_primary = self._generate_ical_file_primary() - self.save(update_fields=["cached_ical_file_primary"]) + try: + self.save(update_fields=["cached_ical_file_primary"]) + except DatabaseError: + # schedule may have been deleted from db + return return self.cached_ical_file_primary def _refresh_primary_ical_file(self): @@ -650,7 +653,11 @@ class OnCallScheduleWeb(OnCallSchedule): """Return cached ical file with iCal events from custom on-call overrides shifts.""" if self.cached_ical_file_overrides is None: self.cached_ical_file_overrides = self._generate_ical_file_overrides() - self.save(update_fields=["cached_ical_file_overrides"]) + try: + self.save(update_fields=["cached_ical_file_overrides"]) + except DatabaseError: + # schedule may have been deleted from db + return return self.cached_ical_file_overrides def _refresh_overrides_ical_file(self): diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index bb13cf5b..cce70ccf 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -5,14 +5,14 @@ import pytest import pytz from django.utils import timezone +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import ( list_of_oncall_shifts_from_ical, list_users_to_notify_from_ical, parse_event_uid, users_in_ical, ) -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar -from common.constants.role import Role +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb @pytest.mark.django_db @@ -26,13 +26,10 @@ def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_u @pytest.mark.django_db -@pytest.mark.parametrize( - "include_viewers", - [True, False], -) +@pytest.mark.parametrize("include_viewers", [True, False]) def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization, include_viewers): organization, user = make_organization_and_user() - viewer = make_user_for_organization(organization, Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) usernames = [user.username, viewer.username] result = users_in_ical(usernames, organization, include_viewers=include_viewers) @@ -43,15 +40,12 @@ def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_f @pytest.mark.django_db -@pytest.mark.parametrize( - "include_viewers", - [True, False], -) +@pytest.mark.parametrize("include_viewers", [True, False]) def test_list_users_to_notify_from_ical_viewers_inclusion( make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift, include_viewers ): organization, user = make_organization_and_user() - viewer = make_user_for_organization(organization, Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) date = timezone.now().replace(tzinfo=None, microsecond=0) @@ -80,6 +74,39 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( assert set(users_on_call) == {user} +@pytest.mark.django_db +def test_list_users_to_notify_from_ical_until_terminated_event( + make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift +): + organization, user = make_organization_and_user() + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + date = timezone.now().replace(tzinfo=None, microsecond=0) + + data = { + "start": date, + "duration": timezone.timedelta(hours=4), + "rotation_start": date + timezone.timedelta(days=3), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "by_day": ["SU"], + "interval": 1, + "until": date + timezone.timedelta(hours=8), + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user], [other_user]]) + + # get users on-call + date = date + timezone.timedelta(minutes=5) + # this should not raise despite the shift configuration (until < rotation start) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert users_on_call == [] + + @pytest.mark.django_db def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ical): calendar = get_ical("calendar_with_all_day_event.ics") diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 32a1d826..e95cbbfa 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -4,9 +4,9 @@ import pytest import pytz from django.utils import timezone +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import memoized_users_in_ical from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb -from common.constants.role import Role @pytest.mark.django_db @@ -18,7 +18,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched name="test_web_schedule", ) user = make_user_for_organization(organization) - viewer = make_user_for_organization(organization, role=Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) @@ -190,7 +190,7 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati schedule_class=OnCallScheduleWeb, name="test_web_schedule", ) - user = make_user_for_organization(organization, role=Role.VIEWER) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) @@ -330,17 +330,23 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma ) on_call_shift.add_rolling_users([[user]]) - # override: 22-23 / E - override_data = { - "start": start_date + timezone.timedelta(hours=22), - "rotation_start": start_date + timezone.timedelta(hours=22), - "duration": timezone.timedelta(hours=1), - "schedule": schedule, - } - override = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + overrides = ( + # user, priority, start time (h), duration (hs) + (user_e, 0, 22, 1), # 22-23 / E + (user_a, 1, 22, 0.5), # 22-22:30 / A ) - override.add_rolling_users([[user_e]]) + for user, priority, start_h, duration in overrides: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(hours=duration), + "priority_level": priority, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data + ) + on_call_shift.add_rolling_users([[user]]) returned_events = schedule.final_events("UTC", start_date, days=1) @@ -357,7 +363,8 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma (18, 1, "A", 1, False, False), # 18-19 A (19, 1, None, None, True, False), # 19-20 gap (20, 2, "D", 2, False, False), # 20-22 D - (22, 1, "E", None, False, True), # 22-23 E (override) + (22, 0.5, "A", 1, False, True), # 22-22:30 A (override the override) + (22.5, 0.5, "E", None, False, True), # 22:30-23 E (override) (23, 1, "B", 1, False, False), # 23-00 B ) expected_events = [ @@ -924,3 +931,37 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m schedule.refresh_from_db() users = schedule.related_users() assert users == set(u.public_primary_key for u in [user_a, user_d, user_e]) + + +@pytest.mark.django_db(transaction=True) +def test_filter_events_none_cache_unchanged( + make_organization, make_user_for_organization, make_schedule, make_on_call_shift +): + organization = make_organization() + user = make_user_for_organization(organization) + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + start_date = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # add shift + data = { + "start": start_date + timezone.timedelta(hours=36), + "rotation_start": start_date + timezone.timedelta(hours=36), + "duration": timezone.timedelta(hours=2), + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + # schedule is removed from db + schedule.delete() + + events = schedule.filter_events("UTC", start_date, days=5, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) + expected = [] + assert events == expected diff --git a/engine/apps/slack/models/slack_team_identity.py b/engine/apps/slack/models/slack_team_identity.py index 3abefc6e..2c8bc4a8 100644 --- a/engine/apps/slack/models/slack_team_identity.py +++ b/engine/apps/slack/models/slack_team_identity.py @@ -4,10 +4,11 @@ from django.apps import apps from django.db import models from django.db.models import JSONField +from apps.api.permissions import RBACPermission from apps.slack.constants import SLACK_INVALID_AUTH_RESPONSE, SLACK_WRONG_TEAM_NAMES from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException -from common.constants.role import Role +from apps.user_management.models.user import User from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log logger = logging.getLogger(__name__) @@ -127,8 +128,10 @@ class SlackTeamIdentity(models.Model): sc = SlackClientWithErrorHandling(self.bot_access_token) members = self.get_conversation_members(sc, channel_id) - users = organization.users.filter(slack_user_identity__slack_id__in=members, role__in=[Role.ADMIN, Role.EDITOR]) - return users + return organization.users.filter( + slack_user_identity__slack_id__in=members, + **User.build_permissions_query(RBACPermission.Permissions.CHATOPS_WRITE, organization), + ) def get_conversation_members(self, slack_client, channel_id): try: diff --git a/engine/apps/slack/models/slack_usergroup.py b/engine/apps/slack/models/slack_usergroup.py index 2b5f8fb6..a319f133 100644 --- a/engine/apps/slack/models/slack_usergroup.py +++ b/engine/apps/slack/models/slack_usergroup.py @@ -7,9 +7,10 @@ from django.db import models from django.db.models import JSONField from django.utils import timezone +from apps.api.permissions import RBACPermission from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException -from common.constants.role import Role +from apps.user_management.models.user import User from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) @@ -105,7 +106,8 @@ class SlackUserGroup(models.Model): def get_users_from_members_for_organization(self, organization): return organization.users.filter( - slack_user_identity__slack_id__in=self.members, role__in=[Role.ADMIN, Role.EDITOR] + slack_user_identity__slack_id__in=self.members, + **User.build_permissions_query(RBACPermission.Permissions.CHATOPS_WRITE, organization), ) @classmethod diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 7526843f..8208919d 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -5,8 +5,8 @@ from django.db import transaction from jinja2 import TemplateSyntaxError from rest_framework.response import Response +from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step -from common.constants.role import Role from common.insight_log import EntityEvent, write_resource_insight_log from common.jinja_templater import jinja_template_env @@ -21,7 +21,7 @@ class OpenAlertAppearanceDialogStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "open Alert Appearance" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 62b0be00..ea0bcb6b 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -14,6 +14,7 @@ from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackR from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation from apps.alerts.tasks import custom_button_result from apps.alerts.utils import render_curl_command +from apps.api.permissions import RBACPermission from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME, SLACK_RATE_LIMIT_DELAY from apps.slack.scenarios import scenario_step from apps.slack.scenarios.slack_renderer import AlertGroupLogSlackRenderer @@ -31,7 +32,6 @@ from apps.slack.tasks import ( update_incident_slack_message, ) from apps.slack.utils import get_cache_key_update_incident_slack_message -from common.constants.role import Role from common.utils import clean_markup, is_string_with_visible_characters from .step_mixins import CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin @@ -222,7 +222,7 @@ class InviteOtherPersonToIncident( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "invite to incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -263,7 +263,7 @@ class SilenceGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "silence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -293,7 +293,7 @@ class UnSilenceGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unsilence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -317,7 +317,7 @@ class SelectAttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Select Incident for Attaching to" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -473,7 +473,7 @@ class AttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Attach incident" def process_signal(self, log_record): @@ -536,7 +536,7 @@ class UnAttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Unattach incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -555,7 +555,7 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessCo scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "stop invitation" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -580,7 +580,8 @@ class CustomButtonProcessStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + # TODO: + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "click custom button" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -642,7 +643,7 @@ class ResolveGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "resolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -688,7 +689,7 @@ class UnResolveGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unresolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -711,7 +712,7 @@ class AcknowledgeGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "acknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -737,7 +738,7 @@ class UnAcknowledgeGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unacknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index 007e9535..16be0f21 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -13,7 +13,6 @@ from apps.slack.slack_client.exceptions import ( SlackAPIRateLimitException, SlackAPITokenException, ) -from common.constants.role import Role logger = logging.getLogger(__name__) @@ -162,15 +161,6 @@ class ScenarioStep(object): step = step_class(slack_team_identity) step.process_scenario(slack_user_identity, slack_team_identity, payload, action=action, **kwargs) - def get_permission_denied_prompt(self): - current_role = self.user.get_role_display() - admins_queryset = self.organization.users.filter(role=Role.ADMIN).select_related("slack_user_identity") - admins_verbal = "No admins" - if admins_queryset.count() > 0: - admins_verbal = ", ".join(["<@{}>".format(admin.slack_user_identity.slack_id) for admin in admins_queryset]) - - return current_role, admins_verbal - def open_warning_window(self, payload, warning_text, title=None): if title is None: title = ":warning: Warning" diff --git a/engine/apps/slack/scenarios/step_mixins.py b/engine/apps/slack/scenarios/step_mixins.py index 1c7fdf0b..03ebdd91 100644 --- a/engine/apps/slack/scenarios/step_mixins.py +++ b/engine/apps/slack/scenarios/step_mixins.py @@ -1,11 +1,13 @@ import logging from abc import ABC, abstractmethod +from apps.api.permissions import user_is_authorized + logger = logging.getLogger(__name__) class AccessControl(ABC): - ALLOWED_ROLES = [] + REQUIRED_PERMISSIONS = [] ACTION_VERBOSE = "" def dispatch(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -15,7 +17,7 @@ class AccessControl(ABC): self.send_denied_message(payload) def check_membership(self): - return self.user.role in self.ALLOWED_ROLES + return user_is_authorized(self.user, self.REQUIRED_PERMISSIONS) @abstractmethod def send_denied_message(self, payload): @@ -62,9 +64,7 @@ class IncidentActionsAccessControlMixin(AccessControl): class CheckAlertIsUnarchivedMixin(object): - - ALLOWED_ROLES = [] - + REQUIRED_PERMISSIONS = [] ACTION_VERBOSE = "" def check_alert_is_unarchived(self, slack_team_identity, payload, alert_group, warning=True): diff --git a/engine/apps/slack/tests/test_reset_slack.py b/engine/apps/slack/tests/test_reset_slack.py index 229f1534..b64e7b3b 100644 --- a/engine/apps/slack/tests/test_reset_slack.py +++ b/engine/apps/slack/tests/test_reset_slack.py @@ -7,24 +7,24 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_reset_slack_integration_permissions( - make_organization_and_user_with_plugin_token, role, expected_status, load_slack_urls, make_user_auth_headers + make_organization_and_user_with_plugin_token, load_slack_urls, make_user_auth_headers, role, expected_status ): settings.FEATURE_SLACK_INTEGRATION_ENABLED = True - _, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role=role) client = APIClient() url = reverse("reset-slack") diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index b22e0ba2..439a8f64 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -11,7 +11,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.base.utils import live_settings from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING @@ -533,10 +533,12 @@ class SlackEventApiEndpointView(APIView): class ResetSlackView(APIView): - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) authentication_classes = [PluginAuthentication] - method_permissions = {IsAdmin: {"POST"}} + rbac_permissions = { + "post": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + } def post(self, request): organization = request.auth.organization diff --git a/engine/apps/telegram/models/verification/channel.py b/engine/apps/telegram/models/verification/channel.py index 8d80f03a..1b24bb6e 100644 --- a/engine/apps/telegram/models/verification/channel.py +++ b/engine/apps/telegram/models/verification/channel.py @@ -24,8 +24,8 @@ class TelegramChannelVerificationCode(models.Model): return self.datetime + timezone.timedelta(days=1) < timezone.now() @property - def uuid_with_org_id(self) -> str: - return f"{self.organization.public_primary_key}_{self.uuid}" + def uuid_with_org_uuid(self) -> str: + return f"{self.organization.uuid}_{self.uuid}" @classmethod def uuid_without_org_id(cls, verification_code: str) -> str: diff --git a/engine/apps/telegram/models/verification/personal.py b/engine/apps/telegram/models/verification/personal.py index 299c9993..323b990a 100644 --- a/engine/apps/telegram/models/verification/personal.py +++ b/engine/apps/telegram/models/verification/personal.py @@ -22,8 +22,8 @@ class TelegramVerificationCode(models.Model): return self.datetime + timezone.timedelta(days=1) < timezone.now() @property - def uuid_with_org_id(self) -> str: - return f"{self.user.organization.public_primary_key}_{self.uuid}" + def uuid_with_org_uuid(self) -> str: + return f"{self.user.organization.uuid}_{self.uuid}" @classmethod def uuid_without_org_id(cls, verification_code: str) -> str: diff --git a/engine/apps/telegram/renderers/keyboard.py b/engine/apps/telegram/renderers/keyboard.py index 997f5473..2d207549 100644 --- a/engine/apps/telegram/renderers/keyboard.py +++ b/engine/apps/telegram/renderers/keyboard.py @@ -16,6 +16,25 @@ class Action(Enum): UNSILENCE = "unsilence" +ACTION_TO_CODE_MAP = { + Action.ACKNOWLEDGE.value: 0, + Action.UNACKNOWLEDGE.value: 1, + Action.RESOLVE.value: 2, + Action.UNRESOLVE.value: 3, + Action.SILENCE.value: 4, + Action.UNSILENCE.value: 5, +} + +CODE_TO_ACTION_MAP = { + 0: Action.ACKNOWLEDGE.value, + 1: Action.UNACKNOWLEDGE.value, + 2: Action.RESOLVE.value, + 3: Action.UNRESOLVE.value, + 4: Action.SILENCE.value, + 5: Action.UNSILENCE.value, +} + + class TelegramKeyboardRenderer: def __init__(self, alert_group: AlertGroup): self.alert_group = alert_group @@ -80,13 +99,15 @@ class TelegramKeyboardRenderer: return self._render_button(text=Action.UNSILENCE.value.capitalize(), action=Action.UNSILENCE) def _render_button(self, text: str, action: Action, action_data: Optional[Union[int, str]] = None): - callback_data_args = [self.alert_group.pk, action.value] + action_code = ACTION_TO_CODE_MAP[action.value] + callback_data_args = [self.alert_group.pk, action_code] if action_data is not None: callback_data_args.append(action_data) - # Add org id with 'x-oncall-org-id' prefix to callback data. - # It's a workaroung to pass org_id to the oncall-gateway while proxying requests. - # TODO: switch to json str instead of ':' separated string. - callback_data_args.append(f"x-oncall-org-id{self.alert_group.channel.organization.public_primary_key}") + # Add org id with 'oncall' prefix to callback data. + # It's a workaround to pass oncall-uuid to the oncall-gateway while proxying requests. + # TODO: check if it's possible switch to json str instead of ':' separated string. + # Note, that there is a 64bytes limit to callback data + callback_data_args.append(f"oncall-uuid{self.alert_group.channel.organization.uuid}") button = InlineKeyboardButton(text=text, callback_data=CallbackQueryFactory.encode_data(*callback_data_args)) return button diff --git a/engine/apps/telegram/tests/test_button_update_handler.py b/engine/apps/telegram/tests/test_button_update_handler.py new file mode 100644 index 00000000..5ae49f7d --- /dev/null +++ b/engine/apps/telegram/tests/test_button_update_handler.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock + +import pytest + +from apps.telegram.renderers.keyboard import Action +from apps.telegram.updates.update_handlers.button_press import ButtonPressHandler + + +@pytest.mark.django_db +def test_get_action_context( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, +): + """ + Test to ensure that both legacy action_name and action_code format is supported. + """ + organization, _, _, _ = make_organization_and_user_with_slack_identities() + alert_receive_channel = make_alert_receive_channel( + organization, + ) + alert_group = make_alert_group(alert_receive_channel) + + handler = ButtonPressHandler(MagicMock()) + + ack_data_with_action_name = f"{alert_group.id}:acknowledge:oncall-uuid{organization.uuid}" + ack_data_with_action_code = f"{alert_group.id}:0:oncall-uuid{organization.uuid}" + + unack_data_with_action_name = f"{alert_group.id}:unacknowledge:oncall-uuid{organization.uuid}" + unack_data_with_action_code = f"{alert_group.id}:1:oncall-uuid{organization.uuid}" + + resolve_data_with_action_name = f"{alert_group.id}:resolve:oncall-uuid{organization.uuid}" + resolve_data_with_action_code = f"{alert_group.id}:2:oncall-uuid{organization.uuid}" + + unresolve_data_with_action_name = f"{alert_group.id}:unresolve:oncall-uuid{organization.uuid}" + unresolve_data_with_action_code = f"{alert_group.id}:3:oncall-uuid{organization.uuid}" + + silence_data_with_action_name = f"{alert_group.id}:silence:3600:oncall-uuid{organization.uuid}" + silence_data_with_action_code = f"{alert_group.id}:4:3600:oncall-uuid{organization.uuid}" + + unsilence_data_with_action_name = f"{alert_group.id}:unsilence:oncall-uuid{organization.uuid}" + unsilence_data_with_action_code = f"{alert_group.id}:5:oncall-uuid{organization.uuid}" + + ACTION_TO_DATA_STR = { + Action.ACKNOWLEDGE: [ack_data_with_action_name, ack_data_with_action_code], + Action.UNACKNOWLEDGE: [unack_data_with_action_name, unack_data_with_action_code], + Action.RESOLVE: [resolve_data_with_action_name, resolve_data_with_action_code], + Action.UNRESOLVE: [unresolve_data_with_action_name, unresolve_data_with_action_code], + Action.SILENCE: [silence_data_with_action_name, silence_data_with_action_code], + Action.UNSILENCE: [unsilence_data_with_action_name, unsilence_data_with_action_code], + } + action_context = handler._get_action_context(ack_data_with_action_name) + + for action, data_strings in ACTION_TO_DATA_STR.items(): + for data_str in data_strings: + action_context = handler._get_action_context(data_str) + assert action_context.action.value == action.value diff --git a/engine/apps/telegram/tests/test_keyboard_renderer.py b/engine/apps/telegram/tests/test_keyboard_renderer.py index 50d769d8..d2910211 100644 --- a/engine/apps/telegram/tests/test_keyboard_renderer.py +++ b/engine/apps/telegram/tests/test_keyboard_renderer.py @@ -49,27 +49,27 @@ def test_actions_keyboard_alerting(make_organization, make_alert_receive_channel [ InlineKeyboardButton( text="Acknowledge", - callback_data=f"{alert_group.pk}:acknowledge:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:0:oncall-uuid{organization.uuid}", ) ], [ InlineKeyboardButton( text="Resolve", - callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:2:oncall-uuid{organization.uuid}", ) ], [ InlineKeyboardButton( text="🔕 forever", - callback_data=f"{alert_group.pk}:silence:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:4:oncall-uuid{organization.uuid}", ), InlineKeyboardButton( text="... for 1h", - callback_data=f"{alert_group.pk}:silence:3600:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:4:3600:oncall-uuid{organization.uuid}", ), InlineKeyboardButton( text="... for 4h", - callback_data=f"{alert_group.pk}:silence:14400:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:4:14400:oncall-uuid{organization.uuid}", ), ], ] @@ -97,13 +97,13 @@ def test_actions_keyboard_acknowledged( [ InlineKeyboardButton( text="Unacknowledge", - callback_data=f"{alert_group.pk}:unacknowledge:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:1:oncall-uuid{organization.uuid}", ) ], [ InlineKeyboardButton( text="Resolve", - callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:2:oncall-uuid{organization.uuid}", ) ], ] @@ -131,7 +131,7 @@ def test_actions_keyboard_resolved( [ InlineKeyboardButton( text="Unresolve", - callback_data=f"{alert_group.pk}:unresolve:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:3:oncall-uuid{organization.uuid}", ) ], ] @@ -159,19 +159,19 @@ def test_actions_keyboard_silenced( [ InlineKeyboardButton( text="Acknowledge", - callback_data=f"{alert_group.pk}:acknowledge:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:0:oncall-uuid{organization.uuid}", ) ], [ InlineKeyboardButton( text="Resolve", - callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:2:oncall-uuid{organization.uuid}", ) ], [ InlineKeyboardButton( text="Unsilence", - callback_data=f"{alert_group.pk}:unsilence:x-oncall-org-id{organization.public_primary_key}", + callback_data=f"{alert_group.pk}:5:oncall-uuid{organization.uuid}", ) ], ] diff --git a/engine/apps/telegram/tests/test_message_renderer.py b/engine/apps/telegram/tests/test_message_renderer.py index 7d800975..862d1c50 100644 --- a/engine/apps/telegram/tests/test_message_renderer.py +++ b/engine/apps/telegram/tests/test_message_renderer.py @@ -72,7 +72,7 @@ def test_alert_group_message(make_organization, make_alert_receive_channel, make renderer = TelegramMessageRenderer(alert_group=alert_group) text = renderer.render_alert_group_message() assert text == ( - f"🔴 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" + f"🔴 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" "Alerting, alerts: 1\n" "Source: Test integration - Grafana\n" f"{alert_group.web_link}\n\n" @@ -156,7 +156,7 @@ def test_personal_message( text = renderer.render_personal_message() assert text == ( - f"🟠 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" + f"🟠 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" f"Acknowledged by {user_name}, alerts: 1\n" "Source: Test integration - Grafana\n" f"{alert_group.web_link}\n\n" diff --git a/engine/apps/telegram/tests/test_models.py b/engine/apps/telegram/tests/test_models.py index 0a9497e4..16a22b93 100644 --- a/engine/apps/telegram/tests/test_models.py +++ b/engine/apps/telegram/tests/test_models.py @@ -17,7 +17,7 @@ def test_user_verification_handler_process_update_another_account_already_linked user_2 = make_user_for_organization(organization) code = make_telegram_verification_code(user_2) - connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_id, chat_id, "nickname") + connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_uuid, chat_id, "nickname") assert created assert connector.telegram_chat_id == chat_id @@ -38,7 +38,7 @@ def test_user_verification_handler_process_update_user_already_linked( other_chat_id = 321 code = make_telegram_verification_code(user_1) - connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_id, other_chat_id, "nickname") + connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_uuid, other_chat_id, "nickname") assert created is False assert connector.user == user_1 diff --git a/engine/apps/telegram/updates/update_handlers/button_press.py b/engine/apps/telegram/updates/update_handlers/button_press.py index 6afa11a5..f97fdabd 100644 --- a/engine/apps/telegram/updates/update_handlers/button_press.py +++ b/engine/apps/telegram/updates/update_handlers/button_press.py @@ -4,12 +4,12 @@ from typing import Callable, Optional, Tuple from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup +from apps.api.permissions import RBACPermission, user_is_authorized from apps.telegram.models import TelegramToUserConnector -from apps.telegram.renderers.keyboard import Action +from apps.telegram.renderers.keyboard import CODE_TO_ACTION_MAP, Action from apps.telegram.updates.update_handlers import UpdateHandler from apps.telegram.utils import CallbackQueryFactory from apps.user_management.models import User -from common.constants.role import Role logger = logging.getLogger(__name__) @@ -58,22 +58,36 @@ class ButtonPressHandler(UpdateHandler): if not user: return False - return user.organization == alert_group.channel.organization and user.role in [Role.ADMIN, Role.EDITOR] + has_permission = user_is_authorized(user, [RBACPermission.Permissions.CHATOPS_WRITE]) + return user.organization == alert_group.channel.organization and has_permission - @staticmethod - def _get_action_context(data: str) -> ActionContext: + @classmethod + def _get_action_context(cls, data: str) -> ActionContext: args = CallbackQueryFactory.decode_data(data) alert_group_pk = args[0] alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) - action_name = args[1] + action_value = args[1] + try: + # if action encoded as action_code - cast it to the action_string + action_value = int(action_value) + action_name = CODE_TO_ACTION_MAP[action_value] + except ValueError: + # support legacy messages with action_name in callback data + action_name = action_value action = Action(action_name) - action_data = args[2] if len(args) >= 3 and not args[2].startswith("x-oncall-org-id") else None + action_data = args[2] if len(args) >= 3 and not cls._is_oncall_identifier(args[2]) else None return ActionContext(alert_group=alert_group, action=action, action_data=action_data) + @staticmethod + def _is_oncall_identifier(string: str) -> bool: + # determines if piece of data passed via callback_data is oncall_identifier + # x-oncall-org-id is kept here for backward compatibility. + return string.startswith("x-oncall-org-id") or string.startswith("oncall-uuid") + @staticmethod def _map_action_context_to_fn(action_context: ActionContext) -> Tuple[Callable, dict]: action_to_fn = { diff --git a/engine/apps/telegram/utils.py b/engine/apps/telegram/utils.py index 18640c58..be878e7a 100644 --- a/engine/apps/telegram/utils.py +++ b/engine/apps/telegram/utils.py @@ -1,7 +1,8 @@ import re from typing import List, Union -TELEGRAM_VERIFICATION_CODE_REGEX = "^[A-Z0-9]*_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +uuid_regex = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +TELEGRAM_VERIFICATION_CODE_REGEX = f"^{uuid_regex}_{uuid_regex}$" def is_verification_message(text: str) -> bool: diff --git a/engine/apps/user_management/migrations/0005_rbac_permissions.py b/engine/apps/user_management/migrations/0005_rbac_permissions.py new file mode 100644 index 00000000..560aa144 --- /dev/null +++ b/engine/apps/user_management/migrations/0005_rbac_permissions.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2022-10-25 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0004_auto_20221025_0316'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='is_rbac_permissions_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='permissions', + field=models.JSONField(default=list), + ), + ] diff --git a/engine/apps/user_management/migrations/0006_organization_uuid.py b/engine/apps/user_management/migrations/0006_organization_uuid.py new file mode 100644 index 00000000..ab2e1d2b --- /dev/null +++ b/engine/apps/user_management/migrations/0006_organization_uuid.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.16 on 2022-12-05 07:00 + +from django.db import migrations, models +import uuid + + +def fill_org_uuid(apps, schema_editor): + Organization = apps.get_model('user_management', 'Organization') + orgs_to_update = [] + for org in Organization.objects.all(): + org.uuid = uuid.uuid4() + orgs_to_update.append(org) + Organization.objects.bulk_update(orgs_to_update, ["uuid"], batch_size=5000) + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0005_rbac_permissions'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='uuid', + field=models.UUIDField(null=True), + ), + migrations.RunPython(fill_org_uuid, migrations.RunPython.noop), + migrations.AlterField( + model_name='organization', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 0be08493..db3d7f0d 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -1,5 +1,6 @@ import logging import typing +import uuid from urllib.parse import urljoin from django.apps import apps @@ -45,7 +46,7 @@ class OrganizationQuerySet(models.QuerySet): def create(self, **kwargs): instance = super().create(**kwargs) if settings.FEATURE_MULTIREGION_ENABLED: - create_oncall_connector(instance.public_primary_key, settings.ONCALL_BACKEND_REGION) + create_oncall_connector(instance.uuid, settings.ONCALL_BACKEND_REGION) return instance def delete(self): @@ -129,6 +130,9 @@ class Organization(MaintainableObject): # Slack specific field with general log channel id general_log_channel_id = models.CharField(max_length=100, null=True, default=None) + # uuid used to unuqie identify organization in different clusters + uuid = models.UUIDField(default=uuid.uuid4, editable=False) + # Organization Settings configured from slack ( ACKNOWLEDGE_REMIND_NEVER, @@ -192,6 +196,7 @@ class Organization(MaintainableObject): pricing_version = models.PositiveIntegerField(choices=PRICING_CHOICES, default=FREE_PUBLIC_BETA_PRICING) is_amixr_migration_started = models.BooleanField(default=False) + is_rbac_permissions_enabled = models.BooleanField(default=False) class Meta: unique_together = ("stack_id", "org_id") @@ -281,9 +286,9 @@ class Organization(MaintainableObject): return urljoin(self.grafana_url, "a/grafana-oncall-app/") @property - def web_link_with_id(self): - # It's a workaround to pass org id to the oncall gateway while proxying telegram requests - return urljoin(self.grafana_url, f"a/grafana-oncall-app/?x-oncall-org-id={self.public_primary_key}") + def web_link_with_uuid(self): + # It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests + return urljoin(self.grafana_url, f"a/grafana-oncall-app/?oncall-uuid={self.uuid}") def __str__(self): return f"{self.pk}: {self.org_title}" diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 63c3ce0e..2bfb7998 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -1,4 +1,7 @@ +import json import logging +import typing +from urllib.parse import urljoin from django.apps import apps from django.conf import settings @@ -8,13 +11,26 @@ from django.db.models.signals import post_save from django.dispatch import receiver from emoji import demojize +from apps.api.permissions import ( + LegacyAccessControlCompatiblePermission, + LegacyAccessControlRole, + RBACPermission, + user_is_authorized, +) from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization -from common.constants.role import Role from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) +class PermissionsRegexQuery(typing.TypedDict): + permissions__regex: str + + +class RoleInQuery(typing.TypedDict): + role__in: typing.List[int] + + def generate_public_primary_key_for_user(): prefix = "U" new_public_primary_key = generate_public_primary_key(prefix) @@ -59,8 +75,9 @@ class UserManager(models.Manager): email=user["email"], name=user["name"], username=user["login"], - role=Role[user["role"].upper()], + role=LegacyAccessControlRole[user["role"].upper()], avatar_url=user["avatarUrl"], + permissions=user["permissions"], ) for user in grafana_users.values() if user["userId"] not in existing_user_ids @@ -75,23 +92,31 @@ class UserManager(models.Manager): users_to_update = [] for user in organization.users.filter(user_id__in=existing_user_ids): grafana_user = grafana_users[user.user_id] - g_user_role = Role[grafana_user["role"].upper()] + g_user_role = LegacyAccessControlRole[grafana_user["role"].upper()] + if ( user.email != grafana_user["email"] or user.name != grafana_user["name"] or user.username != grafana_user["login"] or user.role != g_user_role or user.avatar_url != grafana_user["avatarUrl"] + # instead of looping through the array of permission objects, simply take the hash + # of the string representation of the data structures and compare. + # Need to first convert the lists of objects to strings because lists/dicts are not hashable + # (because lists and dicts are not hashable.. as they are mutable) + # https://stackoverflow.com/a/22003440 + or hash(json.dumps(user.permissions)) != hash(json.dumps(grafana_user["permissions"])) ): user.email = grafana_user["email"] user.name = grafana_user["name"] user.username = grafana_user["login"] user.role = g_user_role user.avatar_url = grafana_user["avatarUrl"] + user.permissions = grafana_user["permissions"] users_to_update.append(user) organization.users.bulk_update( - users_to_update, ["email", "name", "username", "role", "avatar_url"], batch_size=5000 + users_to_update, ["email", "name", "username", "role", "avatar_url", "permissions"], batch_size=5000 ) @@ -134,7 +159,7 @@ class User(models.Model): email = models.EmailField() name = models.CharField(max_length=300) username = models.CharField(max_length=300) - role = models.PositiveSmallIntegerField(choices=Role.choices()) + role = models.PositiveSmallIntegerField(choices=LegacyAccessControlRole.choices()) avatar_url = models.URLField() # don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity @@ -153,6 +178,7 @@ class User(models.Model): # is_active = None is used to be able to have multiple deleted users with the same user_id is_active = models.BooleanField(null=True, default=True) + permissions = models.JSONField(null=False, default=list) def __str__(self): return f"{self.pk}: {self.username}" @@ -161,6 +187,10 @@ class User(models.Model): def is_authenticated(self): return True + @property + def avatar_full_url(self): + return urljoin(self.organization.grafana_url, self.avatar_url) + @property def verified_phone_number(self): """ @@ -182,13 +212,14 @@ class User(models.Model): return hasattr(self, "telegram_connection") def self_or_admin(self, user_to_check, organization) -> bool: + has_admin_permission = user_is_authorized(user_to_check, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) return user_to_check.pk == self.pk or ( - user_to_check.role == Role.ADMIN and organization.pk == user_to_check.organization_id + has_admin_permission and organization.pk == user_to_check.organization_id ) @property def is_notification_allowed(self): - return self.role in (Role.ADMIN, Role.EDITOR) + return user_is_authorized(self, [RBACPermission.Permissions.NOTIFICATIONS_READ]) # using in-memory cache instead of redis to avoid pickling python objects # @timed_lru_cache(timeout=100) @@ -244,6 +275,7 @@ class User(models.Model): result = { "username": self.username, + # LEGACY.. role should get removed eventually.. it's probably safe to remove it now? "role": self.get_role_display(), "notification_policies": notification_policies_verbal, } @@ -257,6 +289,24 @@ class User(models.Model): def insight_logs_metadata(self): return {} + @staticmethod + def build_permissions_query( + permission: LegacyAccessControlCompatiblePermission, organization + ) -> typing.Union[PermissionsRegexQuery, RoleInQuery]: + """ + This method returns a django query filter that is compatible with RBAC + as well as legacy "basic" role based authorization. If a permission is provided we simply do + a regex search where the permission column contains the permission value (need to use regex because + the JSON contains method is not supported by sqlite) + + If RBAC is not supported for the org, we make the assumption that we are looking for any users with AT LEAST + the fallback role. Ex: if the fallback role were editor than we would get editors and admins. + """ + if organization.is_rbac_permissions_enabled: + # https://stackoverflow.com/a/50251879 + return PermissionsRegexQuery(permissions__regex=r".*{0}.*".format(permission.value)) + return RoleInQuery(role__lte=permission.fallback_role.value) + # TODO: check whether this signal can be moved to save method of the model @receiver(post_save, sender=User) diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 80826c5a..740b3332 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -14,9 +14,23 @@ logger.setLevel(logging.DEBUG) def sync_organization(organization): client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - api_users, call_status = client.get_users() + rbac_is_enabled = client.is_rbac_enabled_for_organization() + organization.is_rbac_permissions_enabled = rbac_is_enabled - sync_instance_info(organization) + if organization.gcom_token: + gcom_client = GcomAPIClient(organization.gcom_token) + instance_info = gcom_client.get_instance_info(organization.stack_id) + if not instance_info or str(instance_info["orgId"]) != organization.org_id: + return + + organization.stack_slug = instance_info["slug"] + organization.org_slug = instance_info["orgSlug"] + organization.org_title = instance_info["orgName"] + organization.region_slug = instance_info["regionSlug"] + organization.grafana_url = instance_info["url"] + organization.gcom_token_org_last_time_synced = timezone.now() + + api_users = client.get_users(rbac_is_enabled) if api_users: organization.api_token_status = Organization.API_TOKEN_STATUS_OK @@ -34,25 +48,11 @@ def sync_organization(organization): "last_time_synced", "api_token_status", "gcom_token_org_last_time_synced", + "is_rbac_permissions_enabled", ] ) -def sync_instance_info(organization): - if organization.gcom_token: - gcom_client = GcomAPIClient(organization.gcom_token) - instance_info, _ = gcom_client.get_instance_info(organization.stack_id) - if not instance_info or str(instance_info["orgId"]) != organization.org_id: - return - - organization.stack_slug = instance_info["slug"] - organization.org_slug = instance_info["orgSlug"] - organization.org_title = instance_info["orgName"] - organization.region_slug = instance_info["regionSlug"] - organization.grafana_url = instance_info["url"] - organization.gcom_token_org_last_time_synced = timezone.now() - - def sync_users_and_teams(client, api_users, organization): # check if api_users are shaped correctly. e.g. for paused instance, the response is not a list. if not api_users or not isinstance(api_users, (tuple, list)): diff --git a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py index c26b4216..b3b26e4f 100644 --- a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py +++ b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py @@ -1,7 +1,7 @@ import pytest +from apps.api.permissions import LegacyAccessControlRole from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses -from common.constants.role import Role @pytest.mark.django_db @@ -13,8 +13,8 @@ def test_phone_calls_left( make_alert_group, ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) @@ -28,8 +28,8 @@ def test_sms_left( make_organization, make_user_for_organization, make_sms, make_alert_receive_channel, make_alert_group ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) @@ -48,8 +48,8 @@ def test_phone_calls_and_sms_counts_together( make_alert_group, ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index d6a6d922..c896e725 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -6,11 +6,12 @@ from django.core.exceptions import ObjectDoesNotExist from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient from apps.user_management.models import Team, User from apps.user_management.sync import cleanup_organization, sync_organization +from conftest import IS_RBAC_ENABLED @pytest.mark.django_db def test_sync_users_for_organization(make_organization, make_user_for_organization): - organization = make_organization() + organization = make_organization(grafana_url="https://test.test") users = tuple(make_user_for_organization(organization, user_id=user_id) for user_id in (1, 2)) api_users = tuple( @@ -20,7 +21,8 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati "name": "Test", "login": "test", "role": "admin", - "avatarUrl": "test.test/test", + "avatarUrl": "/test/1234", + "permissions": [], } for user_id in (2, 3) ) @@ -37,12 +39,14 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati assert updated_user is not None assert updated_user.name == api_users[0]["name"] assert updated_user.email == api_users[0]["email"] + assert updated_user.avatar_full_url == "https://test.test/test/1234" # check that missing users are created created_user = organization.users.filter(user_id=api_users[1]["userId"]).first() assert created_user is not None assert created_user.user_id == api_users[1]["userId"] assert created_user.name == api_users[1]["name"] + assert created_user.avatar_full_url == "https://test.test/test/1234" @pytest.mark.django_db @@ -95,11 +99,7 @@ def test_sync_users_for_team(make_organization, make_user_for_organization, make @pytest.mark.django_db -def test_sync_organization( - make_organization, - make_team, - make_user_for_organization, -): +def test_sync_organization(make_organization, make_team, make_user_for_organization): organization = make_organization() api_users_response = ( @@ -110,6 +110,7 @@ def test_sync_organization( "login": "test", "role": "admin", "avatarUrl": "test.test/test", + "permissions": [], }, ) @@ -133,10 +134,11 @@ def test_sync_organization( }, ) - with patch.object(GrafanaAPIClient, "get_users", return_value=(api_users_response, {"status_code": 200})): - with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): - with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): - sync_organization(organization) + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=IS_RBAC_ENABLED): + with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): + with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): + with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): + sync_organization(organization) # check that users are populated assert organization.users.count() == 1 @@ -152,6 +154,9 @@ def test_sync_organization( assert team.users.count() == 1 assert team.users.get() == user + # check that the rbac flag is properly set on the org + assert organization.is_rbac_permissions_enabled == IS_RBAC_ENABLED + @pytest.mark.django_db def test_duplicate_user_ids(make_organization, make_user_for_organization): @@ -176,6 +181,7 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): "login": "test", "role": "admin", "avatarUrl": "test.test/test", + "permissions": [], } ] @@ -190,7 +196,7 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): def test_cleanup_organization_deleted(make_organization): organization = make_organization(gcom_token="TEST_GCOM_TOKEN") - with patch.object(GcomAPIClient, "get_instance_info", return_value=({"status": "deleted"}, None)): + with patch.object(GcomAPIClient, "get_instance_info", return_value={"status": "deleted"}): cleanup_organization(organization.id) with pytest.raises(ObjectDoesNotExist): diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 74440cd1..6928d489 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -1,20 +1,15 @@ -# from unittest.mock import Mock, patch - import pytest +from apps.api.permissions import LegacyAccessControlRole from apps.user_management.models import User -from common.constants.role import Role @pytest.mark.django_db -def test_self_or_admin( - make_organization, - make_user_for_organization, -): +def test_self_or_admin(make_organization, make_user_for_organization): organization = make_organization() admin = make_user_for_organization(organization) second_admin = make_user_for_organization(organization) - editor = make_user_for_organization(organization, role=Role.EDITOR) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) another_organization = make_organization() admin_from_another_organization = make_user_for_organization(another_organization) @@ -26,10 +21,7 @@ def test_self_or_admin( @pytest.mark.django_db -def test_lower_email_filter( - make_organization, - make_user_for_organization, -): +def test_lower_email_filter(make_organization, make_user_for_organization): organization = make_organization() user = make_user_for_organization(organization, email="TestingUser@test.com") make_user_for_organization(organization, email="testing_user@test.com") diff --git a/engine/celery_with_exporter.sh b/engine/celery_with_exporter.sh index d1e020e9..8d7da630 100755 --- a/engine/celery_with_exporter.sh +++ b/engine/celery_with_exporter.sh @@ -28,7 +28,6 @@ CELERY_ARGS=( "--quiet" # --quite parameter removes pointless banner when celery starts "-A" "engine" "worker" - "-l" "info" "--concurrency=$CELERY_WORKER_CONCURRENCY" "--max-tasks-per-child=$CELERY_WORKER_MAX_TASKS_PER_CHILD" "-Q" "$CELERY_WORKER_QUEUE" @@ -36,5 +35,19 @@ CELERY_ARGS=( if [[ $CELERY_WORKER_BEAT_ENABLED = True ]]; then CELERY_ARGS+=("--beat") fi +if [[ $CELERY_WORKER_WITHOUT_MINGLE = True ]]; then + CELERY_ARGS+=("--without-mingle") +fi +if [[ $CELERY_WORKER_WITHOUT_GOSSIP = True ]]; then + CELERY_ARGS+=("--without-gossip") +fi +if [[ $CELERY_WORKER_WITHOUT_HEARTBEAT = True ]]; then + CELERY_ARGS+=("--without-heartbeat") +fi +if [[ $CELERY_WORKER_DEBUG_LOGS = True ]]; then + CELERY_ARGS+=("-l" "debug") +else + CELERY_ARGS+=("-l" "info") +fi celery "${CELERY_ARGS[@]}" diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 7a2f84c0..c3a0991d 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -4,7 +4,6 @@ import math from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django.utils.functional import cached_property -from jinja2.exceptions import TemplateRuntimeError from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound, Throttled @@ -23,6 +22,7 @@ from apps.base.messaging import get_messaging_backends from apps.user_management.models import Team from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class UpdateSerializerMixin: @@ -324,13 +324,16 @@ class PreviewTemplateMixin: templater.template_manager = PreviewTemplateLoader() try: templated_alert = templater.render() - except TemplateRuntimeError: - raise BadRequest(detail={"template_body": "Invalid template syntax"}) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return Response({"preview": e.fallback_message}, status.HTTP_200_OK) templated_attr = getattr(templated_alert, attr_name) elif attr_name in TEMPLATE_NAMES_WITHOUT_NOTIFICATION_CHANNEL: - templated_attr, _ = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) + try: + templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return Response({"preview": e.fallback_message}, status.HTTP_200_OK) else: templated_attr = None response = {"preview": templated_attr} diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 5ccc93b1..aef44887 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -1,7 +1,6 @@ import datetime from urllib.parse import urljoin -import pytz import requests from django.conf import settings from django.utils import dateparse, timezone @@ -9,6 +8,7 @@ from icalendar import Calendar from rest_framework import serializers from common.api_helpers.exceptions import BadRequest +from common.timezones import raise_exception_if_not_valid_timezone class CurrentOrganizationDefault: @@ -84,10 +84,7 @@ def get_date_range_from_request(request): Used mainly for schedules and shifts API. """ user_tz = request.query_params.get("user_tz", "UTC") - try: - pytz.timezone(user_tz) - except pytz.exceptions.UnknownTimeZoneError: - raise BadRequest(detail="Invalid tz format") + raise_exception_if_not_valid_timezone(user_tz) date = timezone.now().date() date_param = request.query_params.get("date") diff --git a/engine/common/constants/role.py b/engine/common/constants/role.py deleted file mode 100644 index 69a05d04..00000000 --- a/engine/common/constants/role.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import IntEnum - - -class Role(IntEnum): - ADMIN = 0 - EDITOR = 1 - VIEWER = 2 - - @classmethod - def choices(cls): - return tuple((option.value, option.name) for option in cls) diff --git a/engine/common/jinja_templater/apply_jinja_template.py b/engine/common/jinja_templater/apply_jinja_template.py index fb00ab31..3a640e25 100644 --- a/engine/common/jinja_templater/apply_jinja_template.py +++ b/engine/common/jinja_templater/apply_jinja_template.py @@ -1,12 +1,42 @@ -from jinja2 import TemplateSyntaxError, UndefinedError +import logging + +from django.conf import settings +from jinja2 import TemplateAssertionError, TemplateSyntaxError, UndefinedError +from jinja2.exceptions import SecurityError from .jinja_template_env import jinja_template_env +logger = logging.getLogger(__name__) + + +class JinjaTemplateError(Exception): + def __init__(self, fallback_message): + self.fallback_message = f"Template Error: {fallback_message}" + + +class JinjaTemplateWarning(Exception): + def __init__(self, fallback_message): + self.fallback_message = f"Template Warning: {fallback_message}" + + +def apply_jinja_template(template, payload=None, result_length_limit=settings.JINJA_RESULT_MAX_LENGTH, **kwargs): + if len(template) > settings.JINJA_TEMPLATE_MAX_LENGTH: + raise JinjaTemplateError( + f"Template exceeds length limit ({len(template)} > {settings.JINJA_TEMPLATE_MAX_LENGTH})" + ) -def apply_jinja_template(template, payload=None, **kwargs): try: - template = jinja_template_env.from_string(template) - result = template.render(payload=payload, **kwargs) - return result, True - except (UndefinedError, TypeError, ValueError, KeyError, TemplateSyntaxError): - return None, False + compiled_template = jinja_template_env.from_string(template) + result = compiled_template.render(payload=payload, **kwargs) + except SecurityError as e: + logger.warning(f"SecurityError process template={template} payload={payload}") + raise JinjaTemplateError(str(e)) + except (TemplateAssertionError, TemplateSyntaxError) as e: + raise JinjaTemplateError(str(e)) + except (TypeError, KeyError, ValueError, UndefinedError) as e: + raise JinjaTemplateWarning(str(e)) + except Exception as e: + logger.error(f"Unexpected template error: {str(e)} template={template} payload={payload}") + raise JinjaTemplateError(str(e)) + + return (result[:result_length_limit] + "..") if len(result) > result_length_limit else result diff --git a/engine/common/jinja_templater/jinja_template_env.py b/engine/common/jinja_templater/jinja_template_env.py index 41e915aa..747b010b 100644 --- a/engine/common/jinja_templater/jinja_template_env.py +++ b/engine/common/jinja_templater/jinja_template_env.py @@ -1,13 +1,20 @@ from django.utils import timezone from jinja2 import BaseLoader +from jinja2.exceptions import SecurityError from jinja2.sandbox import SandboxedEnvironment from .filters import datetimeformat, iso8601_to_time, regex_replace, to_pretty_json + +def raise_security_exception(name): + raise SecurityError(f"use of '{name}' is restricted") + + jinja_template_env = SandboxedEnvironment(loader=BaseLoader()) jinja_template_env.filters["datetimeformat"] = datetimeformat jinja_template_env.filters["iso8601_to_time"] = iso8601_to_time jinja_template_env.filters["tojson_pretty"] = to_pretty_json jinja_template_env.globals["time"] = timezone.now +jinja_template_env.globals["range"] = lambda *args: raise_security_exception("range") jinja_template_env.filters["regex_replace"] = regex_replace diff --git a/engine/common/oncall_gateway/oncall_gateway_client.py b/engine/common/oncall_gateway/oncall_gateway_client.py index 63d48613..77af439d 100644 --- a/engine/common/oncall_gateway/oncall_gateway_client.py +++ b/engine/common/oncall_gateway/oncall_gateway_client.py @@ -71,8 +71,8 @@ class OnCallGatewayAPIClient: response = self._post(url=self._slack_connectors_url, json=d) response_data = response.json() return ( - OnCallConnector( - response_data["oncall_org_id"], + SlackConnector( + response_data["slack_team_id"], response_data["backend"], ), response, diff --git a/engine/common/tests/test_apply_jinja_template.py b/engine/common/tests/test_apply_jinja_template.py new file mode 100644 index 00000000..aed42af1 --- /dev/null +++ b/engine/common/tests/test_apply_jinja_template.py @@ -0,0 +1,65 @@ +import json + +import pytest +from django.conf import settings + +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning + + +def test_apply_jinja_template(): + payload = {"name": "test"} + rendered = apply_jinja_template("{{ payload | tojson_pretty }}", payload) + result = json.loads(rendered) + assert payload == result + + +def test_apply_jinja_template_bad_syntax_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{%", payload={}) + + +def test_apply_jinja_template_unknown_filter_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{ payload | to_json_pretty }}", payload={}) + + +def test_apply_jinja_template_unsafe_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{ payload.__init__() }}", payload={}) + + +def test_apply_jinja_template_restricted_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{% for n in range(100) %}Hello{% endfor %}", payload={}) + + +def test_apply_jinja_template_restricted_inside_conditional(): + template = "{% if 'blabla' in payload %}{% for n in range(100) %}Hello{% endfor %}{% endif %}" + # No exception when condition == False + apply_jinja_template(template, payload={}) + with pytest.raises(JinjaTemplateError): + apply_jinja_template(template, payload={"blabla": "test"}) + + +def test_apply_jinja_template_missing_field_warning(): + with pytest.raises(JinjaTemplateWarning): + apply_jinja_template("{{ payload.field.name }}", payload={}) + + +def test_apply_jinja_template_type_warning(): + with pytest.raises(JinjaTemplateWarning): + apply_jinja_template("{{ payload.name + 25 }}", payload={"name": "test"}) + + +def test_apply_jinja_template_too_large(): + template = "test" * 20000 + with pytest.raises(JinjaTemplateError): + apply_jinja_template(template, payload={}) + + +def test_apply_jinja_template_result_truncate(): + payload = {"value": "test" * 20000} + result = apply_jinja_template("{{ payload.value }}", payload) + # Length == Limit + 2 to account for '..' appended to end + assert len(result) == settings.JINJA_RESULT_MAX_LENGTH + 2 diff --git a/engine/common/tests/test_timezones.py b/engine/common/tests/test_timezones.py new file mode 100644 index 00000000..e5b53e3c --- /dev/null +++ b/engine/common/tests/test_timezones.py @@ -0,0 +1,97 @@ +import pytest +import pytz +from rest_framework import serializers +from rest_framework.exceptions import APIException + +import common.timezones as tz +from common.api_helpers.exceptions import BadRequest + + +@pytest.mark.parametrize( + "input,expected", + [ + ("UTC", pytz.timezone("UTC")), + ("asdfasdfasdf", False), + ], +) +def test_is_valid_timezone(input, expected): + assert tz.is_valid_timezone(input) == expected + + +@pytest.mark.parametrize( + "input,raises_exception", + [ + ("UTC", False), + ("asdfasdfasdf", True), + ], +) +def test_raise_exception_if_not_valid_timezone(input, raises_exception): + if raises_exception: + with pytest.raises(BadRequest, match="Invalid timezone"): + tz.raise_exception_if_not_valid_timezone(input) + else: + try: + tz.raise_exception_if_not_valid_timezone(input) + except Exception: + pytest.fail() + + +def test_raise_exception_if_not_valid_timezone_custom_exception(): + class MyCustomException(APIException): + "asdfasdf" + + with pytest.raises(MyCustomException, match="Invalid timezone"): + tz.raise_exception_if_not_valid_timezone("asdfasfd", exception_class=MyCustomException) + + +class TestTimeZoneField: + @pytest.mark.parametrize("tz", pytz.all_timezones) + def test_valid_timezones(self, tz): + class MySerializer(serializers.Serializer): + tz = tz.TimeZoneField() + + try: + serializer = MySerializer(data={"tz": tz}) + serializer.is_valid(raise_exception=True) + + assert serializer.validated_data["tz"] == tz + except Exception: + pytest.fail() + + def test_invalid_timezone(self): + class MySerializer(serializers.Serializer): + tz = tz.TimeZoneField() + + with pytest.raises(serializers.ValidationError, match="Invalid timezone"): + serializer = MySerializer(data={"tz": "potato"}) + serializer.is_valid(raise_exception=True) + + def test_it_works_with_allow_null(self): + class MySerializer(serializers.Serializer): + tz = tz.TimeZoneField(allow_null=True) + + try: + serializer = MySerializer(data={"tz": None}) + serializer.is_valid(raise_exception=True) + assert serializer.validated_data["tz"] is None + + serializer = MySerializer(data={"tz": "UTC"}) + serializer.is_valid(raise_exception=True) + assert serializer.validated_data["tz"] == "UTC" + except Exception: + pytest.fail() + + def test_it_works_with_required(self): + class MySerializer(serializers.Serializer): + tz = tz.TimeZoneField(required=True) + + with pytest.raises(serializers.ValidationError, match="This field is required"): + serializer = MySerializer(data={}) + serializer.is_valid(raise_exception=True) + + try: + serializer = MySerializer(data={"tz": "UTC"}) + serializer.is_valid(raise_exception=True) + assert serializer.validated_data["tz"] == "UTC" + except Exception: + pytest.fail() diff --git a/engine/common/timezones.py b/engine/common/timezones.py new file mode 100644 index 00000000..0d1c18ae --- /dev/null +++ b/engine/common/timezones.py @@ -0,0 +1,30 @@ +import pytz +from rest_framework import serializers + +from common.api_helpers.exceptions import BadRequest + + +def is_valid_timezone(timezone: str): + try: + return pytz.timezone(timezone) + except pytz.UnknownTimeZoneError: + return False + + +def raise_exception_if_not_valid_timezone(timezone, exception_class=BadRequest): + """ + Like `is_valid_timezone` but throws specified "exception_class" class + (default `common.api_helpers.exceptions.BadRequest`) if not a valid timezone. + + **NOTE**: if `exception_class` is provided, it should take a `detail` kwarg in its constructor + """ + if not is_valid_timezone(timezone): + raise exception_class(detail="Invalid timezone") + + +class TimeZoneField(serializers.CharField): + def _validator(self, value: str): + raise_exception_if_not_valid_timezone(value, serializers.ValidationError) + + def __init__(self, **kwargs): + super().__init__(validators=[self._validator], **kwargs) diff --git a/engine/config_integrations/README.md b/engine/config_integrations/README.md index 13c7189d..e4eaef6b 100644 --- a/engine/config_integrations/README.md +++ b/engine/config_integrations/README.md @@ -1,21 +1,33 @@ # Contribute the new Integration to OnCall -Related: [DEVELOPER.md](/DEVELOPER.md) +Related: [DEVELOPER.md](../../dev/README.md) -"Integration" in OnCall is a pre-configured webhook for alert consumption from alert sources. Usually, alert sources are monitoring systems such as Grafana or Zabbix. +"Integration" in OnCall is a pre-configured webhook for alert consumption from alert sources. Usually, alert sources +are monitoring systems such as Grafana or Zabbix. -Integration is a set of "templates" which are dumped from the integration config once the integration is created. Further changes to "templates" don't reflect on the integration config. Read more about templates [here](https://grafana.com/docs/oncall/latest/integrations/create-custom-templates/). +Integration is a set of "templates" which are dumped from the integration config once the integration is created. +Further changes to "templates" don't reflect on the integration config. Read more about templates +[here](https://grafana.com/docs/oncall/latest/integrations/create-custom-templates/). -This instruction is supposed to help you to build templates to integrate OnCall with a new source of alerts. If you don't want to contribute to OnCall and are looking for a help integrating with custom alert source as a user, refer to [this](https://grafana.com/docs/oncall/latest/integrations/create-custom-templates/) instruction. +This instruction is supposed to help you to build templates to integrate OnCall with a new source of alerts. If you +don't want to contribute to OnCall and are looking for a help integrating with custom alert source as a user, +refer to [this](https://grafana.com/docs/oncall/latest/integrations/create-custom-templates/) instruction. + +## Files related to Integrations -# Files related to Integrations 0. Refer to "Grafana" integration as the most complete example. -1. Each integration should have a `{{integration_name_in_snake_case}}.py` file in `/engine/config_integrations`. There you'll find Templates that will be copied to the Integration Templates once the integration is created by the user in the OnCall UI; Example Payload; and Tests which should match the result of the rendering of Example Payload as using Templates. The best way to build such a file is to create Webhook Integration, write & debug templates in the UI first and copy-paste them to the file after. +1. Each integration should have a `{{integration_name_in_snake_case}}.py` file in `/engine/config_integrations`. + There you'll find Templates that will be copied to the Integration Templates once the integration is created by the + user in the OnCall UI; Example Payload; and Tests which should match the result of the rendering of Example Payload + as using Templates. The best way to build such a file is to create Webhook Integration, write & debug templates in + the UI first and copy-paste them to the file after. 2. Each integration should be listed in the `/engine/settings/base.py` file, section `INSTALLED_ONCALL_INTEGRATIONS`. -3. Each integration should have "How to connect" instruction stored as `integration_{{integration_name_in_snake_case}}.html` in the `engine/apps/integrations/html` folder. `.py` file has a `slug` field that is used to locate `.html` file. +3. Each integration should have "How to connect" instruction stored as `integration_{{integration_name_in_snake_case}}.html` + in the `engine/apps/integrations/html` folder. `.py` file has a `slug` field that is used to locate `.html` file. -# What do we expect from high-quality integration? +## What do we expect from high-quality integration? 1. User-friendly integration instruction. -2. Proper grouping following source's logics. If source generates multiple alerts per "detection" it would be nice to provide suitable grouping & resolving configuration in the templates. +2. Proper grouping following source's logics. If source generates multiple alerts per "detection" it would be nice to + provide suitable grouping & resolving configuration in the templates. 3. Awesome rendering. We all love when alerts look good in Slack, SMS and all other rendering destinations. diff --git a/engine/conftest.py b/engine/conftest.py index 7ac895b7..11db43ec 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -1,4 +1,5 @@ import json +import os import sys import typing import uuid @@ -35,6 +36,13 @@ from apps.alerts.tests.factories import ( ResolutionNoteFactory, ResolutionNoteSlackMessageFactory, ) +from apps.api.permissions import ( + ACTION_PREFIX, + GrafanaAPIPermission, + LegacyAccessControlCompatiblePermission, + LegacyAccessControlRole, + RBACPermission, +) from apps.auth_token.models import ApiAuthToken, PluginAuthToken from apps.base.models.user_notification_policy_log_record import ( UserNotificationPolicyLogRecord, @@ -72,7 +80,6 @@ from apps.telegram.tests.factories import ( from apps.twilioapp.tests.factories import PhoneCallFactory, SMSFactory from apps.user_management.models.user import User, listen_for_user_model_save from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory -from common.constants.role import Role register(OrganizationFactory) register(UserFactory) @@ -112,6 +119,8 @@ register(EmailMessageFactory) register(IntegrationHeartBeatFactory) register(LiveSettingFactory) +IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" + @pytest.fixture(autouse=True) def mock_slack_api_call(monkeypatch): @@ -142,18 +151,16 @@ def mock_telegram_bot_username(monkeypatch): @pytest.fixture def make_organization(): def _make_organization(**kwargs): - organization = OrganizationFactory(**kwargs) - - return organization + return OrganizationFactory(**kwargs, is_rbac_permissions_enabled=IS_RBAC_ENABLED) return _make_organization @pytest.fixture -def make_user_for_organization(): - def _make_user_for_organization(organization, role=Role.ADMIN, **kwargs): +def make_user_for_organization(make_user): + def _make_user_for_organization(organization, role: typing.Optional[LegacyAccessControlRole] = None, **kwargs): post_save.disconnect(listen_for_user_model_save, sender=User) - user = UserFactory(organization=organization, role=role, **kwargs) + user = make_user(organization=organization, role=role, **kwargs) post_save.disconnect(listen_for_user_model_save, sender=User) return user @@ -200,19 +207,84 @@ def make_user_auth_headers(): return _make_user_auth_headers +RoleMapping = typing.Dict[LegacyAccessControlRole, typing.List[LegacyAccessControlCompatiblePermission]] + + +def get_user_permission_role_mapping_from_frontend_plugin_json() -> RoleMapping: + """ + This is used to take the RBAC permission -> basic role grants on the frontend + and test that the RBAC grants work the same way against the backend in terms of authorization + """ + + class PluginJSONRoleDefinition(typing.TypedDict): + permissions: typing.List[GrafanaAPIPermission] + + class PluginJSONRole(typing.TypedDict): + role: PluginJSONRoleDefinition + grants: typing.List[str] + + class PluginJSON(typing.TypedDict): + roles: typing.List[PluginJSONRole] + + with open("../grafana-plugin/src/plugin.json") as fp: + plugin_json: PluginJSON = json.load(fp) + + role_mapping: RoleMapping = { + LegacyAccessControlRole.VIEWER: [], + LegacyAccessControlRole.EDITOR: [], + LegacyAccessControlRole.ADMIN: [], + } + + all_permission_classes: typing.Dict[str, LegacyAccessControlCompatiblePermission] = { + getattr(RBACPermission.Permissions, attr).value: getattr(RBACPermission.Permissions, attr) + for attr in dir(RBACPermission.Permissions) + if not attr.startswith("_") + } + + # we just care about getting the basic role grants, everything else can be ignored + for role in plugin_json["roles"]: + if grants := role["grants"]: + for permission in role["role"]["permissions"]: + # only concerned with grafana-oncall-app specific grants + # ignore things like plugins.app:access actions + action = permission["action"] + permission_class = None + + if action.startswith(ACTION_PREFIX): + permission_class = all_permission_classes[action] + + if permission_class: + for grant in grants: + try: + role = LegacyAccessControlRole[grant.upper()] + if role not in role_mapping[role]: + role_mapping[role].append(permission_class) + except KeyError: + # may come across grants like "Grafana Admin" + # which we can ignore + continue + + return role_mapping + + +ROLE_PERMISSION_MAPPING = get_user_permission_role_mapping_from_frontend_plugin_json() + + @pytest.fixture def make_user(): - def _make_user(role=Role.ADMIN, **kwargs): - user = UserFactory(role=role, **kwargs) - - return user + def _make_user(role: typing.Optional[LegacyAccessControlRole] = None, **kwargs): + role = LegacyAccessControlRole.ADMIN if role is None else role + permissions = ROLE_PERMISSION_MAPPING[role] if IS_RBAC_ENABLED else [] + return UserFactory( + role=role, permissions=[GrafanaAPIPermission(action=perm.value) for perm in permissions], **kwargs + ) return _make_user @pytest.fixture def make_organization_and_user(make_organization, make_user_for_organization): - def _make_organization_and_user(role=Role.ADMIN): + def _make_organization_and_user(role: typing.Optional[LegacyAccessControlRole] = None): organization = make_organization() user = make_user_for_organization(organization=organization, role=role) return organization, user @@ -224,33 +296,31 @@ def make_organization_and_user(make_organization, make_user_for_organization): def make_organization_and_user_with_slack_identities( make_organization_with_slack_team_identity, make_user_with_slack_user_identity ): - def _make_organization_and_user_with_slack_identities(role=Role.ADMIN): + def _make_organization_and_user_with_slack_identities(role: typing.Optional[LegacyAccessControlRole] = None): organization, slack_team_identity = make_organization_with_slack_team_identity() user, slack_user_identity = make_user_with_slack_user_identity(slack_team_identity, organization, role=role) - return organization, user, slack_team_identity, slack_user_identity return _make_organization_and_user_with_slack_identities @pytest.fixture -def make_user_with_slack_user_identity(): - def _make_slack_user_identity_with_user(slack_team_identity, organization, role=Role.ADMIN, **kwargs): - slack_user_identity = SlackUserIdentityFactory( - slack_team_identity=slack_team_identity, - **kwargs, - ) - user = UserFactory(slack_user_identity=slack_user_identity, organization=organization, role=role) +def make_user_with_slack_user_identity(make_user): + def _make_slack_user_identity_with_user( + slack_team_identity, organization, role: typing.Optional[LegacyAccessControlRole] = None, **kwargs + ): + slack_user_identity = SlackUserIdentityFactory(slack_team_identity=slack_team_identity, **kwargs) + user = make_user(slack_user_identity=slack_user_identity, organization=organization, role=role) return user, slack_user_identity return _make_slack_user_identity_with_user @pytest.fixture -def make_organization_with_slack_team_identity(make_slack_team_identity): +def make_organization_with_slack_team_identity(make_slack_team_identity, make_organization): def _make_slack_team_identity_with_organization(**kwargs): slack_team_identity = make_slack_team_identity(**kwargs) - organization = OrganizationFactory(slack_team_identity=slack_team_identity) + organization = make_organization(slack_team_identity=slack_team_identity) return organization, slack_team_identity return _make_slack_team_identity_with_organization @@ -565,10 +635,9 @@ def mock_start_disable_maintenance_task(monkeypatch): @pytest.fixture() def make_organization_and_user_with_plugin_token(make_organization_and_user, make_token_for_organization): - def _make_organization_and_user_with_plugin_token(role=Role.ADMIN): - organization, user = make_organization_and_user(role=role) + def _make_organization_and_user_with_plugin_token(role: typing.Optional[LegacyAccessControlRole] = None): + organization, user = make_organization_and_user(role) _, token = make_token_for_organization(organization) - return organization, user, token return _make_organization_and_user_with_plugin_token diff --git a/engine/engine/celery.py b/engine/engine/celery.py index d21e968c..c023eb6e 100644 --- a/engine/engine/celery.py +++ b/engine/engine/celery.py @@ -1,13 +1,20 @@ +import logging import os +import time import celery from celery.app.log import TaskFormatter +from celery.utils.debug import memdump, sample_mem +from celery.utils.log import get_task_logger +from django.conf import settings -# set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.prod") from django.db import connection # noqa: E402 +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + connection.cursor() from celery import Celery # noqa: E402 @@ -38,6 +45,20 @@ def on_after_setup_logger(logger, **kwargs): for handler in logger.handlers: handler.setFormatter( TaskFormatter( - "%(asctime)s source=engine:celery task_id=%(task_id)s task_name=%(task_name)s name=%(name)s level=%(levelname)s %(message)s" + "%(asctime)s source=engine:celery worker=%(processName)s task_id=%(task_id)s task_name=%(task_name)s name=%(name)s level=%(levelname)s %(message)s" ) ) + + +if settings.DEBUG_CELERY_TASKS_PROFILING: + + @celery.signals.task_prerun.connect + def start_task_timer(task_id=None, task=None, *a, **kw): + logger.info("started: {} of {} with cpu={} at {}".format(task_id, task.name, time.perf_counter(), time.time())) + sample_mem() + + @celery.signals.task_postrun.connect + def finish_task_timer(task_id=None, task=None, *a, **kw): + logger.info("ended: {} of {} with cpu={} at {}".format(task_id, task.name, time.perf_counter(), time.time())) + sample_mem() + memdump() diff --git a/engine/engine/middlewares.py b/engine/engine/middlewares.py index cf5878ad..5422670d 100644 --- a/engine/engine/middlewares.py +++ b/engine/engine/middlewares.py @@ -28,7 +28,12 @@ class RequestTimeLoggingMiddleware(MiddlewareMixin): f"latency={str(seconds)} status={status_code} method={request.method} path={request.path} " f"content-length={content_length} slow={int(seconds > settings.SLOW_THRESHOLD_SECONDS)} " ) - if hasattr(request, "user") and request.user and request.user.id: + if ( + hasattr(request, "user") + and request.user + and request.user.id + and hasattr(request.user, "organization_id") + ): user_id = request.user.id org_id = request.user.organization_id message += f"user_id={user_id} org_id={org_id} " diff --git a/engine/engine/urls.py b/engine/engine/urls.py index ab6934d8..e5e6768e 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -53,7 +53,8 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: urlpatterns += [ - path("mobile_app/", include("apps.mobile_app.urls")), + path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")), + path("api/internal/v1/mobile_app/", include("apps.mobile_app.urls", namespace="mobile_app_tmp")), ] diff --git a/engine/requirements.txt b/engine/requirements.txt index 3c5463f1..5e41b01d 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -41,3 +41,4 @@ psycopg2-binary==2.9.3 emoji==1.7.0 apns2==0.7.2 regex==2021.11.2 +psutil==5.9.4 diff --git a/engine/settings/base.py b/engine/settings/base.py index 49036059..680b8249 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -39,6 +39,8 @@ MIRAGE_CIPHER_MODE = "CBC" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False +DEBUG_CELERY_TASKS_PROFILING = getenv_boolean("DEBUG_CELERY_TASKS_PROFILING", False) + ALLOWED_HOSTS = [item.strip() for item in os.environ.get("ALLOWED_HOSTS", "*").split(",")] # TODO: update link to up-to-date docs @@ -550,16 +552,18 @@ if FEATURE_MOBILE_APP_INTEGRATION_ENABLED: ] PUSH_NOTIFICATIONS_SETTINGS = { - "FCM_API_KEY": os.environ.get("FCM_API_KEY", None), - "GCM_API_KEY": os.environ.get("GCM_API_KEY", None), + "FCM_API_KEY": os.getenv("FCM_API_KEY"), + "FCM_POST_URL": os.getenv("FCM_POST_URL", default="https://fcm.googleapis.com/fcm/send"), + "USER_MODEL": "user_management.User", + "UPDATE_ON_DUPLICATE_REG_ID": True, + # TODO: remove APNS related endpoints after the hackathon app is deprecated "APNS_AUTH_KEY_PATH": os.environ.get("APNS_AUTH_KEY_PATH", None), "APNS_TOPIC": os.environ.get("APNS_TOPIC", None), "APNS_AUTH_KEY_ID": os.environ.get("APNS_AUTH_KEY_ID", None), "APNS_TEAM_ID": os.environ.get("APNS_TEAM_ID", None), "APNS_USE_SANDBOX": getenv_boolean("APNS_USE_SANDBOX", True), - "USER_MODEL": "user_management.User", - "UPDATE_ON_DUPLICATE_REG_ID": True, } +FCM_RELAY_ENABLED = getenv_boolean("FCM_RELAY_ENABLED", default=False) SELF_HOSTED_SETTINGS = { "STACK_ID": 5, @@ -574,6 +578,9 @@ SELF_HOSTED_SETTINGS = { GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) DATA_UPLOAD_MAX_MEMORY_SIZE = getenv_integer("DATA_UPLOAD_MAX_MEMORY_SIZE", 1_048_576) # 1mb by default +JINJA_TEMPLATE_MAX_LENGTH = 50000 +JINJA_RESULT_TITLE_MAX_LENGTH = 500 +JINJA_RESULT_MAX_LENGTH = 50000 # Log inbound/outbound calls as slow=1 if they exceed threshold SLOW_THRESHOLD_SECONDS = 2.0 diff --git a/engine/tox.ini b/engine/tox.ini index 0a721f1e..7cabc843 100644 --- a/engine/tox.ini +++ b/engine/tox.ini @@ -9,6 +9,6 @@ banned-modules = [pytest] # https://pytest-django.readthedocs.io/en/latest/configuring_django.html#order-of-choosing-settings # https://pytest-django.readthedocs.io/en/latest/database.html -addopts = --reuse-db --nomigrations --color=yes --showlocals +addopts = --color=yes --showlocals # https://pytest-django.readthedocs.io/en/latest/faq.html#my-tests-are-not-being-found-why python_files = tests.py test_*.py *_tests.py diff --git a/grafana-plugin/babel.config.json b/grafana-plugin/babel.config.json index 3776c289..2cba8024 100644 --- a/grafana-plugin/babel.config.json +++ b/grafana-plugin/babel.config.json @@ -10,6 +10,6 @@ "@babel/plugin-transform-runtime", "@babel/proposal-class-properties", "@babel/transform-regenerator", - "@babel/plugin-transform-template-literals", + "@babel/plugin-transform-template-literals" ] } diff --git a/grafana-plugin/jest.config.js b/grafana-plugin/jest.config.js index 305169f5..ed696b01 100644 --- a/grafana-plugin/jest.config.js +++ b/grafana-plugin/jest.config.js @@ -14,10 +14,12 @@ module.exports = { 'jest/outgoingWebhooksStub': '/src/jest/outgoingWebhooksStub.ts', '^jest$': '/src/jest', '^.+\\.(css|scss)$': '/src/jest/styleMock.ts', - // '^.+\\.(ts|tsx)$': 'ts-jest', '^lodash-es$': 'lodash', '^.+\\.svg$': '/src/jest/svgTransform.ts', + '^.+\\.png$': '/src/jest/grafanaMock.ts', }, setupFilesAfterEnv: ['/jest.setup.ts'], + + testTimeout: 10000, }; diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 46d3cc39..0c74a2a9 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -56,7 +56,7 @@ "@babel/preset-typescript": "^7.18.6", "@grafana/data": "^9.2.4", "@grafana/eslint-config": "^5.0.0", - "@grafana/runtime": "^9.2.4", + "@grafana/runtime": "9.3.0-beta1", "@grafana/toolkit": "^9.2.4", "@grafana/ui": "^9.2.4", "@jest/globals": "^27.5.1", @@ -117,6 +117,7 @@ "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", "react-modal": "^3.15.1", + "react-qr-code": "^2.0.8", "react-responsive": "^8.1.0", "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", diff --git a/grafana-plugin/src/__mocks__/grafana/app/core/core.ts b/grafana-plugin/src/__mocks__/grafana/app/core/core.ts deleted file mode 100644 index abe4971e..00000000 --- a/grafana-plugin/src/__mocks__/grafana/app/core/core.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const contextSrv = { - hasRole: jest.fn(), -}; diff --git a/grafana-plugin/src/assets/img/brand/apple-logo.svg b/grafana-plugin/src/assets/img/brand/apple-logo.svg new file mode 100644 index 00000000..acfaa37e --- /dev/null +++ b/grafana-plugin/src/assets/img/brand/apple-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/grafana-plugin/src/assets/img/brand/play-store-logo.svg b/grafana-plugin/src/assets/img/brand/play-store-logo.svg new file mode 100644 index 00000000..e4e5150d --- /dev/null +++ b/grafana-plugin/src/assets/img/brand/play-store-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/grafana-plugin/src/assets/img/qr-code.png b/grafana-plugin/src/assets/img/qr-code.png new file mode 100644 index 00000000..2211b2ad Binary files /dev/null and b/grafana-plugin/src/assets/img/qr-code.png differ diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 71c38142..873bdd57 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -17,8 +17,8 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Alert } from 'models/alertgroup/alertgroup.types'; import { makeRequest } from 'network'; -import { UserAction } from 'state/userAction'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import styles from './AlertTemplatesForm.module.css'; @@ -27,7 +27,6 @@ const cx = cn.bind(styles); interface AlertTemplatesFormProps { templates: any; onUpdateTemplates: (values: any) => void; - errors: any; alertReceiveChannelId: AlertReceiveChannel['id']; alertGroupId?: Alert['pk']; demoAlertEnabled: boolean; @@ -154,7 +153,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { There are no alerts from this monitoring yet. {demoAlertEnabled ? ( - + @@ -241,7 +240,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { ))} - + diff --git a/grafana-plugin/src/components/GBlock/Block.module.css b/grafana-plugin/src/components/GBlock/Block.module.scss similarity index 61% rename from grafana-plugin/src/components/GBlock/Block.module.css rename to grafana-plugin/src/components/GBlock/Block.module.scss index 49f4ed2d..b83e6c79 100644 --- a/grafana-plugin/src/components/GBlock/Block.module.css +++ b/grafana-plugin/src/components/GBlock/Block.module.scss @@ -1,6 +1,18 @@ .root { padding: 16px; border-radius: 2px; + + &--withBackGround { + background: var(--secondary-background); + } + + &--fullWidth { + width: 100%; + } + + &--hover:hover { + background: var(--hover-selected); + } } :global(.theme-dark) .root_bordered { @@ -14,7 +26,3 @@ :global(.theme-dark) .root_shadowed { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); } - -.root_with-background { - background: var(--secondary-background); -} diff --git a/grafana-plugin/src/components/GBlock/Block.tsx b/grafana-plugin/src/components/GBlock/Block.tsx index 16fd7260..f3a4dc0e 100644 --- a/grafana-plugin/src/components/GBlock/Block.tsx +++ b/grafana-plugin/src/components/GBlock/Block.tsx @@ -2,25 +2,39 @@ import React, { FC, HTMLAttributes } from 'react'; import cn from 'classnames/bind'; -import styles from './Block.module.css'; +import styles from './Block.module.scss'; interface BlockProps extends HTMLAttributes { bordered?: boolean; shadowed?: boolean; withBackground?: boolean; + hover?: boolean; + fullWidth?: boolean; } const cx = cn.bind(styles); const Block: FC = (props) => { - const { children, style, className, bordered = false, shadowed = false, withBackground = false, ...rest } = props; + const { + children, + style, + className, + bordered = false, + fullWidth = false, + hover = false, + shadowed = false, + withBackground = false, + ...rest + } = props; return (
= (props) => { Configure rotations and shifts directly in Grafana On-Call - - diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 52c47cd1..05eca27f 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -20,7 +20,7 @@ import { } from 'models/escalation_policy/escalation_policy.types'; import { WaitDelay } from 'models/wait_delay'; import { SelectOption } from 'state/types'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import DragHandle from './DragHandle'; import PolicyNote from './PolicyNote'; @@ -53,14 +53,14 @@ export class EscalationPolicy extends React.Component - + {escalationOption && reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)} {this._renderNote()} {is_final ? null : ( - + + + + { - + Edit teams diff --git a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx index 8695d4e6..41047296 100644 --- a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx +++ b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx @@ -12,8 +12,8 @@ import { HeartGreenIcon, HeartRedIcon } from 'icons'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { SelectOption } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import styles from './HeartbeatForm.module.css'; @@ -90,7 +90,7 @@ const HeartbeatForm = observer(({ alertReceveChannelId, onUpdate }: HeartBeatMod

OnCall will issue an incident if no alert is received every - + {

- + diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.css b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.css deleted file mode 100644 index 1fd5ef88..00000000 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.input { - flex-grow: 1; -} - -.telegram-code { - font-weight: bolder; - padding: 0 4px; -} diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.scss b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.scss new file mode 100644 index 00000000..cd4af8f4 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.scss @@ -0,0 +1,60 @@ +.container { + display: flex; + flex-direction: row; + + &__box { + flex-basis: 50%; + } + + &__box:first-child { + margin-right: 8px; + } + &__box:last-child { + margin-left: 8px; + } +} + +.icon { + margin-top: -6px; + margin-left: 4px; + fill: var(--green-6); +} + +.disconnect__container { + position: relative; + display: flex; + justify-content: center; + width: 100%; +} + +.disconnect__qrCode { + width: 240px; + height: auto; + filter: blur(6px); + opacity: 0.6; +} + +.blurry { + filter: blur(4px); + opacity: 0.2; +} + +.qr-loader { + position: absolute; + z-index: 10; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + + &__text { + text-align: center; + margin-bottom: 12px; + display: block; + } + + i { // Overwrite Grafana's loading icon + font-size: 32px; + } + +} diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx new file mode 100644 index 00000000..00a2d6aa --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { UserStore } from 'models/user/user'; +import { User } from 'models/user/user.types'; +import { RootStore } from 'state'; +import { useStore as useStoreOriginal } from 'state/useStore'; + +import MobileAppVerification from './MobileAppVerification'; + +jest.mock('state/useStore'); + +const useStore = useStoreOriginal as jest.Mock>; +const loadUserMock = jest.fn().mockReturnValue(undefined); + +const mockUseStore = (rest?: any, connected = false) => { + const store = { + userStore: { + loadUser: loadUserMock, + currentUser: { + messaging_backends: { + MOBILE_APP: { connected }, + }, + } as unknown as User, + ...(rest ? rest : {}), + } as unknown as UserStore, + } as unknown as RootStore; + + useStore.mockReturnValue(store); + + return store; +}; + +const USER_PK = '8585'; +const BACKEND = 'MOBILE_APP'; + +describe('MobileAppVerification', () => { + beforeEach(() => { + loadUserMock.mockClear(); + }); + + test('it shows a loading message if it is currently fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test('it shows a message when the mobile app is already connected', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }, + true + ); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + }); + }); + + test('it shows an error message if there was an error fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockRejectedValueOnce('dfd'), + }); + + const component = render(); + await screen.findByText(/.*error fetching your QR code.*/); + + await waitFor(() => { + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test("it shows a QR code if the app isn't already connected", async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test('if we disconnect the app, it disconnects and fetches a new QR code', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await userEvent.click(button); + // click the confirm button within the modal, which actually triggers the callback + await userEvent.click(screen.getByText('Remove')); + + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test('it shows a loading message if it is currently disconnecting', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500))), + }, + true + ); + + const component = render(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await userEvent.click(button); + // click the confirm button within the modal, which actually triggers the callback + await userEvent.click(screen.getByText('Remove')); + + // wait for loading state + await screen.findByText(/.*Loading.*/); + + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test('it shows an error message if there was an error disconnecting the mobile app', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + const button = await screen.findByTestId('test__disconnect'); + + // click the disconnect button, which opens the modal + await userEvent.click(button); + // click the confirm button within the modal, which actually triggers the callback + await userEvent.click(screen.getByText('Remove')); + + await screen.findByText(/.*error disconnecting your mobile app.*/); + + expect(component.container).toMatchSnapshot(); + + await waitFor(() => { + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + }); + + test('it polls loadUser on first render if not connected', async () => { + mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'), + }, + false + ); + + render(); + + await waitFor( + () => { + expect(loadUserMock).toHaveBeenCalled(); + }, + { timeout: 6000 } + ); + }); + + test('it polls loadUser after disconnect', async () => { + mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'), + unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'), + }, + true + ); + + render(); + const button = await screen.findByRole('button'); + + loadUserMock.mockClear(); + + await userEvent.click(button); // click the disconnect button, which opens the modal + await userEvent.click(screen.getByText('Remove')); // click the confirm button within the modal, which actually triggers the callback + + await waitFor( + () => { + expect(loadUserMock).toHaveBeenCalled(); + }, + { timeout: 6000 } + ); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index 5413c270..ed634a9f 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -1,105 +1,234 @@ -import React, { HTMLAttributes, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, LoadingPlaceholder } from '@grafana/ui'; +import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; +import qrCodeImage from 'assets/img/qr-code.png'; +import Block from 'components/GBlock/Block'; import Text from 'components/Text/Text'; -import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; -import styles from './MobileAppVerification.module.css'; +import styles from './MobileAppVerification.module.scss'; +import DisconnectButton from './parts/DisconnectButton/DisconnectButton'; +import DownloadIcons from './parts/DownloadIcons'; +import QRCode from './parts/QRCode/QRCode'; const cx = cn.bind(styles); -interface MobileAppVerificationProps extends HTMLAttributes { - userPk?: User['pk']; - phone?: string; -} +type Props = { + userPk: User['pk']; +}; -const MobileAppVerification = observer((props: MobileAppVerificationProps) => { - const { userPk: propsUserPk } = props; +const INTERVAL_MIN_THROTTLING = 500; +const INTERVAL_QUEUE_QR = 50000; +const INTERVAL_POLLING = 5000; +const BACKEND = 'MOBILE_APP'; - const store = useStore(); - const { userStore } = store; +const MobileAppVerification = observer(({ userPk }: Props) => { + const { userStore } = useStore(); - const userPk = (propsUserPk || userStore.currentUserPk) as User['pk']; - const user = userStore.items[userPk as User['pk']]; - const isCurrent = userStore.currentUserPk === user.pk; - const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const isMounted = useRef(false); + const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState(isUserConnected()); - const [showMobileAppVerificationToken, setShowMobileAppVerificationToken] = useState(undefined); - const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState(false); - const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState(true); + const [fetchingQRCode, setFetchingQRCode] = useState(!mobileAppIsCurrentlyConnected); + const [QRCodeValue, setQRCodeValue] = useState(null); + const [errorFetchingQRCode, setErrorFetchingQRCode] = useState(null); - const handleCreateMobileAppVerificationToken = async () => { - setIsMobileAppVerificationTokenExisting(true); - await userStore - .sendBackendConfirmationCode(userPk, 'MOBILE_APP') - .then((res) => setShowMobileAppVerificationToken(res)); - }; + const [disconnectingMobileApp, setDisconnectingMobileApp] = useState(false); + const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState(null); + const [userTimeoutId, setUserTimeoutId] = useState(undefined); + const [refreshTimeoutId, setRefreshTimeoutId] = useState(undefined); + const [isQRBlurry, setIsQRBlurry] = useState(false); - useEffect(() => { - handleCreateMobileAppVerificationToken().then(() => { - setMobileAppVerificationTokenLoading(false); - }); + const fetchQRCode = useCallback( + async (showLoader = true) => { + if (showLoader) { + setFetchingQRCode(true); + } + + try { + // backend verification code that we receive is a JSON object that has been "stringified" + const qrCodeContent = await userStore.sendBackendConfirmationCode(userPk, BACKEND); + setQRCodeValue(qrCodeContent); + } catch (e) { + setErrorFetchingQRCode('There was an error fetching your QR code. Please try again.'); + } + + if (showLoader) { + setFetchingQRCode(false); + } + }, + [userPk] + ); + + const resetState = useCallback(() => { + setErrorDisconnectingMobileApp(null); + setMobileAppIsCurrentlyConnected(false); + setQRCodeValue(null); }, []); + const disconnectMobileApp = useCallback(async () => { + setDisconnectingMobileApp(true); + + try { + await userStore.unlinkBackend(userPk, BACKEND); + resetState(); + } catch (e) { + setErrorDisconnectingMobileApp('There was an error disconnecting your mobile app. Please try again.'); + } + + setDisconnectingMobileApp(false); + clearTimeouts(); + triggerTimeouts(); + }, [userPk, resetState]); + + useEffect(() => { + isMounted.current = true; + + if (!isUserConnected()) { + triggerTimeouts(); + } + + // clear on unmount + return () => { + isMounted.current = false; + clearTimeouts(); + }; + }, []); + + useEffect(() => { + if (!mobileAppIsCurrentlyConnected) { + fetchQRCode(); + } + }, [mobileAppIsCurrentlyConnected]); + + let content: React.ReactNode = null; + + if (fetchingQRCode || disconnectingMobileApp) { + content = ; + } else if (errorFetchingQRCode || errorDisconnectingMobileApp) { + content = {errorFetchingQRCode || errorDisconnectingMobileApp}; + } else if (mobileAppIsCurrentlyConnected) { + content = ( + + + App connected + + + You can sync one application to your account. To setup new device please disconnect app first. + +
+ + +
+
+ ); + } else if (QRCodeValue) { + content = ( + + + Sign In + + Open Grafana IRM mobile application and scan this code to sync it with your account. +
+ + {isQRBlurry && } +
+
+ ); + } + return ( -
- {MobileAppVerificationTokenLoading ? ( - - ) : ( - <> -

- Open Grafana OnCall mobile application and enter the following code to add the new device: -

- {isMobileAppVerificationTokenExisting ? ( - <> - {showMobileAppVerificationToken !== undefined ? ( - <> -

{showMobileAppVerificationToken}

-

- * This code is active only for a minute -

-

- - - -

- - ) : ( - <> - )} - - ) : ( -

- - - -

- )} -

- * Only iOS is currently supported -

- - )} +
+ + + + + {content} +
); + + function clearTimeouts(): void { + clearTimeout(userTimeoutId); + clearTimeout(refreshTimeoutId); + } + + function triggerTimeouts(): void { + setTimeout(queueRefreshQR, INTERVAL_QUEUE_QR); + setTimeout(pollUserProfile, INTERVAL_POLLING); + } + + function isUserConnected(user?: User): boolean { + return !!(user || userStore.currentUser).messaging_backends[BACKEND]?.connected; + } + + async function queueRefreshQR(): Promise { + if (!isMounted.current) { + return; + } + + clearTimeout(refreshTimeoutId); + setRefreshTimeoutId(undefined); + + const user = await userStore.loadUser(userPk); + if (!isUserConnected(user)) { + let didCallThrottleWithNoEffect = false; + let isRequestDone = false; + + const throttle = () => { + if (!isMounted.current) { + return; + } + if (!isRequestDone) { + didCallThrottleWithNoEffect = true; + return; + } + + setIsQRBlurry(false); + setTimeout(queueRefreshQR, INTERVAL_QUEUE_QR); + }; + + setTimeout(throttle, INTERVAL_MIN_THROTTLING); + setIsQRBlurry(true); + + await fetchQRCode(false); + + isRequestDone = true; + if (didCallThrottleWithNoEffect) { + throttle(); + } + } + } + + async function pollUserProfile(): Promise { + if (!isMounted.current) { + return; + } + + clearTimeout(userTimeoutId); + setUserTimeoutId(undefined); + + const user = await userStore.loadUser(userPk); + if (!isUserConnected(user)) { + setUserTimeoutId(setTimeout(pollUserProfile, INTERVAL_POLLING)); + } else { + setMobileAppIsCurrentlyConnected(true); + } + } }); +function QRLoading() { + return ( +
+ + Regenerating QR code... + + +
+ ); +} + export default MobileAppVerification; diff --git a/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap new file mode 100644 index 00000000..e471869c --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap @@ -0,0 +1,2955 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+ + Sign In + +
+
+ + Open Grafana IRM mobile application and scan this code to sync it with your account. + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a message when the mobile app is already connected 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+ + App connected +
+ + + +
+
+
+
+ + You can sync one application to your account. To setup new device please disconnect app first. + +
+
+
+ + +
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+ + There was an error disconnecting your mobile app. Please try again. + +
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+ + There was an error fetching your QR code. Please try again. + +
+
+
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss new file mode 100644 index 00000000..8e4b047d --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss @@ -0,0 +1,6 @@ +.disconnect-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx new file mode 100644 index 00000000..4f106642 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DisconnectButton from './DisconnectButton'; + +describe('DisconnectButton', () => { + test('it renders properly', () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); + + test('It calls the onClick handler when clicked', async () => { + const mockedOnClick = jest.fn(); + + render(); + + // click the button, which opens the modal + await userEvent.click(screen.getByRole('button')); + // click the confirm button within the modal, which actually triggers the callback + await userEvent.click(screen.getByText('Remove')); + + expect(mockedOnClick).toHaveBeenCalledWith(); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx new file mode 100644 index 00000000..435dabb4 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; + +import { Button } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import WithConfirm from 'components/WithConfirm/WithConfirm'; + +import styles from './DisconnectButton.module.scss'; + +const cx = cn.bind(styles); + +type Props = { + onClick: () => void; +}; + +const DisconnectButton: FC = ({ onClick }) => ( + + + +); + +export default DisconnectButton; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap new file mode 100644 index 00000000..22996a57 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DisconnectButton it renders properly 1`] = ` +
+ +
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss new file mode 100644 index 00000000..6f41181e --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss @@ -0,0 +1,16 @@ +.icon { + width: 25px; + height: auto; + margin-right: 12px; +} + +.icon-text, +.icon { + cursor: default; +} + +.icon-block { + display: flex; + align-items: center; + min-height: 80px; +} diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx new file mode 100644 index 00000000..bc2011e3 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import DownloadIcons from './'; + +describe('DownloadIcons', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap new file mode 100644 index 00000000..c355da1b --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadIcons it renders properly 1`] = ` +
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx new file mode 100644 index 00000000..2b140799 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import { VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import AppleLogoSVG from 'assets/img/brand/apple-logo.svg'; +import PlayStoreLogoSVG from 'assets/img/brand/play-store-logo.svg'; +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; + +import styles from './DownloadIcons.module.scss'; + +const cx = cn.bind(styles); + +const DownloadIcons: FC = () => ( + + + Download + + The Grafana IRM app is available on both the App Store and Google Play Store. + + + Apple + + iOS + + + + Play Store + + Android + + + + +); + +export default DownloadIcons; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx new file mode 100644 index 00000000..76008c27 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import QRCode from './QRCode'; + +describe('QRCode', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx new file mode 100644 index 00000000..b41de79d --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; + +import QRCodeBase from 'react-qr-code'; + +import Block from 'components/GBlock/Block'; + +type Props = { + value: string; + className?: string; +}; + +const QRCode: FC = (props: Props) => { + const { value, className = '' } = props; + + return ( + + + + ); +}; + +export default QRCode; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap new file mode 100644 index 00000000..511496e9 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap @@ -0,0 +1,2221 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QRCode it renders properly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+`; diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 47317d42..ff96a67f 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -9,7 +9,7 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { form } from './OutgoingWebhookForm.config'; @@ -56,7 +56,7 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { >
- + diff --git a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx index 16c5ad07..41aa5b16 100644 --- a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx +++ b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx @@ -14,7 +14,7 @@ import { NotificationPolicyType } from 'models/notification_policy'; import { User as UserType } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { getColor } from './PersonalNotificationSettings.helpers'; import img from './img/default-step.png'; @@ -105,7 +105,7 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin const user = userStore.items[userPk]; - const userAction = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateNotificationPolicies; + const userAction = isCurrent ? UserActions.UserSettingsWrite : UserActions.NotificationSettingsWrite; const getPhoneStatus = () => { if (store.hasFeature(AppFeature.CloudNotifications)) { return user.cloud_connection_status; diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx index 14c1ad52..aee193e2 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx @@ -295,14 +295,12 @@ describe('PluginConfigPage', () => { // test setup const component = render(); - const user = userEvent.setup(); - const button = await screen.findByRole('button'); // click the reset button, which opens the modal - await user.click(button); + await userEvent.click(button); // click the confirm button within the modal, which actually triggers the callback - await user.click(screen.getByText('Remove')); + await userEvent.click(screen.getByText('Remove')); await screen.findByTestId(successful ? PLUGIN_CONFIGURATION_FORM_DATA_ID : STATUS_MESSAGE_BLOCK_DATA_ID); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx index ab5ba96c..f577d0b7 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx @@ -20,7 +20,6 @@ const fillOutFormAndTryToSubmit = async (onCallApiUrl: string, selfHostedInstall .mockResolvedValueOnce(selfHostedInstallPluginSuccess ? null : SELF_HOSTED_PLUGIN_API_ERROR_MSG); // setup - const user = userEvent.setup(); const component = render( ); @@ -28,12 +27,12 @@ const fillOutFormAndTryToSubmit = async (onCallApiUrl: string, selfHostedInstall // fill out onCallApiUrl input const input = screen.getByTestId('onCallApiUrl'); - await user.click(input); - await user.clear(input); // clear the input first before typing to wipe out the placeholder text - await user.keyboard(onCallApiUrl); + await userEvent.click(input); + await userEvent.clear(input); // clear the input first before typing to wipe out the placeholder text + await userEvent.keyboard(onCallApiUrl); // submit form - await user.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); return { dom: component.baseElement, mockOnSuccessfulSetup }; }; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap index 08fcbc9e..8d36ea17 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap @@ -209,7 +209,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
{ test('It calls the onClick handler when clicked', async () => { const mockedOnClick = jest.fn(); - const user = userEvent.setup(); render(); // click the button, which opens the modal - await user.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); // click the confirm button within the modal, which actually triggers the callback - await user.click(screen.getByText('Remove')); + await userEvent.click(screen.getByText('Remove')); expect(mockedOnClick).toHaveBeenCalledWith(); expect(mockedOnClick).toHaveBeenCalledTimes(1); diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 352af9f9..c84be627 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -17,7 +17,7 @@ import { Schedule, Shift } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; -import { getDateTime, getStartOfWeek, getUTCString } from 'pages/schedule/Schedule.helpers'; +import { getDateTime, getStartOfWeek, getUTCByDay, getUTCString } from 'pages/schedule/Schedule.helpers'; import { SelectOption } from 'state/types'; import { useStore } from 'state/useStore'; import { getCoords, waitForElement } from 'utils/DOM'; @@ -154,7 +154,10 @@ const RotationForm: FC = observer((props) => { rolling_users: userGroups, interval: repeatEveryValue, frequency: repeatEveryPeriod, - by_day: repeatEveryPeriod === 0 || repeatEveryPeriod === 1 ? selectedDays : null, + by_day: + repeatEveryPeriod === 0 || repeatEveryPeriod === 1 + ? getUTCByDay(store.scheduleStore.byDayOptions, selectedDays, shiftStart) + : null, priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level, }), [ diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index fe8eb348..ec1a65a3 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -16,8 +16,8 @@ import { getColor, getFromString } from 'models/schedule/schedule.helpers'; import { Layer, Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; import { findColor } from './Rotations.helpers'; @@ -112,7 +112,7 @@ class Rotations extends Component {
) : ( - + diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 811797dc..ff3bc9e4 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -15,8 +15,8 @@ import { getOverrideColor, getOverridesFromStore } from 'models/schedule/schedul import { Schedule, ScheduleType, Shift, ShiftEvents } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; import { findColor } from './Rotations.helpers'; @@ -94,7 +94,7 @@ class ScheduleOverrides extends Component ) : ( - + diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 2fb0f480..a926fb4c 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -9,7 +9,7 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config'; import { prepareForEdit } from './ScheduleForm.helpers'; @@ -77,7 +77,7 @@ const ScheduleForm = observer((props: ScheduleFormProps) => {
- + diff --git a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx b/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx index 2b1fdbc5..2eab10f6 100644 --- a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx +++ b/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx @@ -6,7 +6,7 @@ import { observer } from 'mobx-react'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; const SlackIntegrationButton = observer((props: { className: string; disabled?: boolean }) => { const { className, disabled } = props; @@ -35,7 +35,7 @@ const SlackIntegrationButton = observer((props: { className: string; disabled?: if (store.teamStore.currentTeam?.slack_team_identity) { return ( - + diff --git a/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx b/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx index 5f337ce2..4ac1e1e4 100644 --- a/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx +++ b/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx @@ -7,6 +7,7 @@ import { observer } from 'mobx-react'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Alert } from 'models/alertgroup/alertgroup.types'; import { useStore } from 'state/useStore'; +import { openErrorNotification } from 'utils'; import { useDebouncedCallback } from 'utils/hooks'; import sanitize from 'utils/sanitize'; @@ -35,7 +36,15 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => { (alertGroupId ? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody) : alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody) - ).then(setResult); + ) + .then(setResult) + .catch((err) => { + if (err.response?.data?.length > 0) { + openErrorNotification(err.response.data); + } else { + openErrorNotification(err.message); + } + }); }, 1000); useEffect(handleTemplateBodyChange, [templateBody]); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index 14d20420..2e237915 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -7,7 +7,6 @@ import { useMediaQuery } from 'react-responsive'; import { Tabs, TabsContent } from 'containers/UserSettings/parts'; import { User as UserType } from 'models/user/user.types'; -import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { BREAKPOINT_TABS } from 'utils/consts'; @@ -20,12 +19,13 @@ const cx = cn.bind(styles); interface UserFormProps { onHide: () => void; id: UserType['pk'] | 'new'; + showMobileAppScreen: boolean; onCreate?: (data: UserType) => void; onUpdate?: () => void; tab?: UserSettingsTab; } -const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: UserFormProps) => { +const UserSettings = observer(({ id, showMobileAppScreen, onHide, tab = UserSettingsTab.UserInfo }: UserFormProps) => { const store = useStore(); const { userStore, teamStore } = store; @@ -49,14 +49,17 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U }, []); const isModalWide = - !isDesktopOrLaptop || activeTab === UserSettingsTab.UserInfo || activeTab === UserSettingsTab.PhoneVerification; + !isDesktopOrLaptop || + activeTab === UserSettingsTab.UserInfo || + activeTab === UserSettingsTab.PhoneVerification || + activeTab === UserSettingsTab.MobileAppVerification; const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] = [ !isDesktopOrLaptop, isCurrent && teamStore.currentTeam?.slack_team_identity && !storeUser.slack_user_identity, isCurrent && !storeUser.telegram_configuration, - store.hasFeature(AppFeature.MobileApp), + showMobileAppScreen, ]; return ( diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts index 78d2d144..028672af 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts @@ -1,4 +1,4 @@ -import { User, UserRole } from 'models/user/user.types'; +import { User } from 'models/user/user.types'; export enum UserSettingsTab { UserInfo, @@ -12,5 +12,4 @@ export enum UserSettingsTab { export interface UserFormData extends Partial { slack_user_identity_name?: string; telegram_configuration_telegram_nick_name?: string; - role?: UserRole; } diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx index d5637b0e..1b58108d 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx @@ -8,8 +8,8 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { openNotification } from 'utils'; +import { UserActions } from 'utils/authorization'; import styles from './index.module.css'; @@ -88,7 +88,7 @@ const ICalConnector = (props: ICalConnectorProps) => { In case you lost your iCal link you can revoke it and generate a new one. - + diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 24221f4b..9c486a58 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -30,16 +30,14 @@ interface TabsProps { showTelegramConnectionTab: boolean; } -export const Tabs = (props: TabsProps) => { - const { - activeTab, - onTabChange, - showNotificationSettingsTab, - showMobileAppVerificationTab, - showSlackConnectionTab, - showTelegramConnectionTab, - } = props; - +export const Tabs = ({ + activeTab, + onTabChange, + showNotificationSettingsTab, + showMobileAppVerificationTab, + showSlackConnectionTab, + showTelegramConnectionTab, +}: TabsProps) => { const getTabClickHandler = useCallback( (tab: UserSettingsTab) => { return () => { @@ -106,17 +104,13 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = observer((props: TabsContentProps) => { - const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; +export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLaptop }: TabsContentProps) => { + const store = useStore(); + useEffect(() => { store.updateFeatures(); }, []); - const store = useStore(); - const { userStore } = store; - - const storeUser = userStore.items[id]; - return ( {activeTab === UserSettingsTab.UserInfo && @@ -139,9 +133,8 @@ export const TabsContent = observer((props: TabsContentProps) => { ) : ( ))} - {activeTab === UserSettingsTab.MobileAppVerification && ( - - )} + {/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */} + {activeTab === UserSettingsTab.MobileAppVerification && } {activeTab === UserSettingsTab.SlackInfo && } {activeTab === UserSettingsTab.TelegramInfo && } diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 7758b450..ecf2b50c 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -9,8 +9,8 @@ import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; interface CloudPhoneSettingsProps extends WithStoreProps { userPk?: User['pk']; @@ -119,7 +119,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { return ( <> - {store.isUserActionAllowed(UserAction.UpdateOtherUsersSettings) ? ( + {isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( OnCall use Grafana Cloud for SMS and phone call notifications diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx index a06208dc..2a02565e 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx @@ -10,8 +10,8 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { openErrorNotification } from 'utils'; +import { isUserActionAllowed, UserAction, UserActions } from 'utils/authorization'; import styles from './PhoneVerification.module.css'; @@ -137,12 +137,12 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { const isPhoneValid = phoneHasMinimumLength && PHONE_REGEX.test(phone); const showPhoneInputError = phoneHasMinimumLength && !isPhoneValid && !isPhoneNumberHidden && !isLoading; - const action = isCurrentUser ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const action = isCurrentUser ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin; const isButtonDisabled = phone === user.verified_phone_number || (!isCodeSent && !isPhoneValid) || !isTwilioConfigured; const isPhoneDisabled = !!user.verified_phone_number; - const isCodeFieldDisabled = !isCodeSent || !store.isUserActionAllowed(action); + const isCodeFieldDisabled = !isCodeSent || !isUserActionAllowed(action); const showToggle = user.verified_phone_number && isCurrentUser; if (showForgetScreen) { @@ -264,7 +264,7 @@ function ForgetPhoneScreen({ phone, onCancel, onForget }: ForgetPhoneScreenProps } interface PhoneVerificationButtonsGroupProps { - action: UserAction.UpdateOwnSettings | UserAction.UpdateOtherUsersSettings; + action: UserAction; isCodeSent: boolean; isButtonDisabled: boolean; diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx index 3c186429..efc94642 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx @@ -39,13 +39,13 @@ const TelegramInfo = observer((_props: TelegramInfoProps) => { <> {telegramConfigured || !store.hasFeature(AppFeature.LiveSettings) ? ( - Connect personal Telegram + {/* Connect personal Telegram Connect Telegram automatically - + */} Manual connection diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx index d5d81090..a506af70 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx @@ -6,7 +6,6 @@ import cn from 'classnames/bind'; import Text from 'components/Text/Text'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { Connectors } from 'containers/UserSettings/parts/connectors'; -import { getRole } from 'models/user/user.helpers'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -31,7 +30,7 @@ export const UserInfoTab = (props: UserInfoTabProps) => { <>
- To edit user details such as Username, email, and role, please visit{' '} + To edit user details such as Username, email, and roles, please visit{' '} Grafana User settings.
@@ -43,10 +42,6 @@ export const UserInfoTab = (props: UserInfoTabProps) => { {storeUser.email || '—'}
-
- - {getRole(storeUser.role)} -
); diff --git a/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx b/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx index 2215b7a8..1165d376 100644 --- a/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx +++ b/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx @@ -4,8 +4,7 @@ import { Tooltip } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; -import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { isUserActionAllowed, UserAction } from 'utils/authorization'; import styles from './WithPermissionControl.module.css'; @@ -21,9 +20,7 @@ interface WithPermissionControlProps { export const WithPermissionControl = observer((props: WithPermissionControlProps) => { const { userAction, children, className } = props; - const store = useStore(); - - const disabledByPermissions = !store.isUserActionAllowed(userAction); + const disabledByPermissions = !isUserActionAllowed(userAction); const onClickCallback = useCallback( (event: any) => { diff --git a/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx b/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx index b5074956..a3dd5b0b 100644 --- a/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx +++ b/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx @@ -3,8 +3,7 @@ import React, { ReactElement, useMemo } from 'react'; import { Tooltip } from '@grafana/ui'; import { observer } from 'mobx-react'; -import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { isUserActionAllowed, UserAction } from 'utils/authorization'; interface WithPermissionControlProps { userAction: UserAction; @@ -14,9 +13,7 @@ interface WithPermissionControlProps { export const WithPermissionControl = observer((props: WithPermissionControlProps) => { const { userAction, children } = props; - const store = useStore(); - - const disabled = !store.isUserActionAllowed(userAction); + const disabled = !isUserActionAllowed(userAction); const element = useMemo(() => children(disabled), [disabled]); diff --git a/grafana-plugin/src/index.d.ts b/grafana-plugin/src/index.d.ts index 98c6c4b4..05637e54 100644 --- a/grafana-plugin/src/index.d.ts +++ b/grafana-plugin/src/index.d.ts @@ -22,6 +22,12 @@ declare module 'grafana/app/core/core' { // https://github.com/grafana/grafana/blob/main/public/app/core/services/context_srv.ts#L59 export const contextSrv: { - hasRole(role: OrgRole): boolean; + user: { + orgRole: OrgRole | ''; + permissions?: Record; + }; + + hasAccess(action: string, fallBack: boolean): boolean; + accessControlEnabled(): boolean; }; } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 8b703377..c38a69eb 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,9 +6,9 @@ import { User } from 'models/user/user.types'; import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { - 'Calendar', - 'Ical', 'API', + 'Ical', + 'Calendar', } export interface RotationFormLiveParams { diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index 505052db..cf511c96 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -1,5 +1,4 @@ import { SlackChannel } from 'models/slack_channel/slack_channel.types'; -import { UserRole } from 'models/user/user.types'; export enum SubscriptionStatus { OK, @@ -63,7 +62,6 @@ export interface Team { // ex team settings archive_alerts_from: string; is_resolution_note_required: boolean; - user_role_by_default: UserRole; env_status: { twilio_configured: boolean; diff --git a/grafana-plugin/src/models/user.ts b/grafana-plugin/src/models/user.ts index 2c96b801..3b267954 100644 --- a/grafana-plugin/src/models/user.ts +++ b/grafana-plugin/src/models/user.ts @@ -1,7 +1,3 @@ -import { UserAction } from 'state/userAction'; - -import { UserRole } from './user/user.types'; - export interface UserDTO { pk: number; slack_login: string; @@ -16,7 +12,6 @@ export interface UserDTO { verified_phone_number?: string; unverified_phone_number?: string; phone_verified: boolean; - role: UserRole; telegram_configuration: { telegram_nick_name: string; telegram_chat_id: number; @@ -29,6 +24,5 @@ export interface UserDTO { inviter_name: string | null; video_conference_link: string | null; }; - permissions: UserAction[]; trigger_video_call?: boolean; } diff --git a/grafana-plugin/src/models/user/user.config.ts b/grafana-plugin/src/models/user/user.config.ts deleted file mode 100644 index 6c2dcdf3..00000000 --- a/grafana-plugin/src/models/user/user.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserRole } from 'models/user/user.types'; - -export const DEFAULT_USER_ROLES = [ - { display_name: 'Admin', value: UserRole.ADMIN }, - { display_name: 'Editor', value: UserRole.EDITOR }, - { - display_name: 'Viewer', - value: UserRole.VIEWER, - }, -]; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index 7d21b87d..0911b4e9 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -2,33 +2,7 @@ import React from 'react'; import { pick } from 'lodash-es'; -import { User, UserRole } from './user.types'; - -export const getIconType = (role: UserRole) => { - switch (role) { - case UserRole.ADMIN: - return 'crown'; - case UserRole.EDITOR: - return 'user'; - case UserRole.VIEWER: - return 'eye'; - default: - return 'user'; - } -}; - -export const getRole = (role: UserRole) => { - switch (role) { - case UserRole.ADMIN: - return 'Admin'; - case UserRole.EDITOR: - return 'Editor'; - case UserRole.VIEWER: - return 'Viewer'; - default: - return ''; - } -}; +import { User } from './user.types'; export const getTimezone = (user: User) => { return user.timezone || 'UTC'; diff --git a/grafana-plugin/src/models/user/user.test.ts b/grafana-plugin/src/models/user/user.test.ts new file mode 100644 index 00000000..a8f0b681 --- /dev/null +++ b/grafana-plugin/src/models/user/user.test.ts @@ -0,0 +1,56 @@ +import { makeRequest as makeRequestOriginal } from 'network'; +import { RootStore } from 'state'; + +import { UserStore } from './user'; + +const makeRequest = makeRequestOriginal as jest.Mock>; + +jest.mock('network'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('UserStore.sendBackendConfirmationCode', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + const mockedQrCode = 'dfkjfdkjfdkjfdjk'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce(mockedQrCode); + + expect(await userStore.sendBackendConfirmationCode(userPk, backend)).toEqual(mockedQrCode); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + }); +}); + +describe('UserStore.unlinkBackend', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce('hello'); + + userStore.loadCurrentUser = jest.fn(); + + await userStore.unlinkBackend(userPk, backend); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/unlink_backend/?backend=${backend}`, { + method: 'POST', + }); + + expect(userStore.loadCurrentUser).toHaveBeenCalledTimes(1); + expect(userStore.loadCurrentUser).toHaveBeenCalledWith(); + }); +}); diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 8d7ea002..76af6ebc 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -8,6 +8,7 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { move } from 'state/helpers'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; @@ -55,7 +56,7 @@ export class UserStore extends BaseStore { const response = await makeRequest('/user/', {}); let timezone; - if (!response.timezone) { + if (!response.timezone && isUserActionAllowed(UserActions.UserSettingsWrite)) { timezone = dayjs.tz.guess(); this.update(response.pk, { timezone }); } @@ -73,18 +74,15 @@ export class UserStore extends BaseStore { } @action - async loadUser(userPk: User['pk'], skipErrorHandling = false) { + async loadUser(userPk: User['pk'], skipErrorHandling = false): Promise { const user = await this.getById(userPk, skipErrorHandling); this.items = { ...this.items, [user.pk]: { ...user, timezone: getTimezone(user) }, }; - } - @action - getCurrentUser() { - return this.items[this.currentUserPk as User['pk']]; + return user; } @action @@ -106,11 +104,11 @@ export class UserStore extends BaseStore { } @action - async updateItems(f: any = { searchTerm: '', roles: undefined }, page = 1) { + async updateItems(f: any = { searchTerm: '' }, page = 1) { const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility - const { searchTerm: search, roles } = filters; + const { searchTerm: search } = filters; const { count, results } = await makeRequest(this.path, { - params: { search, roles, page }, + params: { search, page }, }); this.items = { @@ -144,10 +142,6 @@ export class UserStore extends BaseStore { return await makeRequest(`/users/${userPk}/get_telegram_verification_code/`, {}); }; - sendBackendConfirmationCode = async (userPk: User['pk'], backend: string) => { - return await makeRequest(`/users/${userPk}/get_backend_verification_code/?backend=${backend}`, {}); - }; - @action unlinkSlack = async (userPk: User['pk']) => { await makeRequest(`/users/${userPk}/unlink_slack/`, { @@ -176,6 +170,11 @@ export class UserStore extends BaseStore { }; }; + sendBackendConfirmationCode = (userPk: User['pk'], backend: string) => + makeRequest(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + @action unlinkBackend = async (userPk: User['pk'], backend: string) => { await makeRequest(`/users/${userPk}/unlink_backend/?backend=${backend}`, { diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 4c7b6fb8..58de40b2 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -1,12 +1,5 @@ import { Team } from 'models/team/team.types'; import { Timezone } from 'models/timezone/timezone.types'; -import { UserAction } from 'state/userAction'; - -export enum UserRole { - ADMIN, - EDITOR, - VIEWER, -} export interface MessagingBackends { [key: string]: any; @@ -25,7 +18,6 @@ export interface User { username: string; slack_id: string; phone_verified: boolean; - role: UserRole; telegram_configuration: { telegram_nick_name: string; telegram_chat_id: number; // TODO check if string @@ -51,7 +43,6 @@ export interface User { inviter_name: string | null; video_conference_link: string | null; }; - permissions: UserAction[]; trigger_video_call?: boolean; export_url?: string; status?: number; diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 16a65c55..d01bd347 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -27,9 +27,9 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; import { pages } from 'pages'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import styles from './EscalationChains.module.css'; @@ -151,7 +151,7 @@ class EscalationChainsPage extends React.Component
- + @@ -149,7 +149,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const unacknowledgeButton = ( - + @@ -157,7 +157,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const unresolveButton = ( - + @@ -165,7 +165,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const acknowledgeButton = ( - + @@ -189,7 +189,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key if (incident.status === IncidentStatus.Silenced) { buttons.push( - + diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index c4f48065..4be78be1 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -49,10 +49,10 @@ import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/r import { pages } from 'pages'; import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import { openNotification } from 'utils'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import sanitize from 'utils/sanitize'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers'; @@ -229,7 +229,7 @@ class IncidentPage extends React.Component #{incident.root_alert_group.inside_organization_number}{' '} {incident.root_alert_group.render_for_web.title} {' '} - + @@ -646,7 +646,7 @@ function AttachedIncidentsList({ #{incident.inside_organization_number} {incident.render_for_web.title} - + diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 198c2da4..10503e7b 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -24,9 +24,9 @@ import { pages } from 'pages'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers'; import { move } from 'state/helpers'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import SilenceDropdown from './parts/SilenceDropdown'; @@ -197,7 +197,7 @@ class Incidents extends React.Component
{'resolve' in store.alertGroupStore.bulkActions && ( - +
- + @@ -165,12 +165,12 @@ class OutgoingWebhooks extends React.Component { return ( - + - + + {schedule?.type === ScheduleType.Ical && ( + + )} + {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && ( @@ -209,8 +209,8 @@ class SchedulesPage extends React.Component { - if (data.type === ScheduleType.API) { - LocationHelper.update({ page: 'schedule', id: data.id }, 'replace'); + if (data.type === ScheduleType.Calendar) { + LocationHelper.update({ page: 'schedule', id: data.id }, 'partial'); } }; @@ -355,10 +355,10 @@ class SchedulesPage extends React.Component { return ( - + - + diff --git a/grafana-plugin/src/pages/settings/SettingsPage.tsx b/grafana-plugin/src/pages/settings/SettingsPage.tsx index be6af07b..1b1ab2cf 100644 --- a/grafana-plugin/src/pages/settings/SettingsPage.tsx +++ b/grafana-plugin/src/pages/settings/SettingsPage.tsx @@ -5,13 +5,13 @@ import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; -import { pages } from 'pages'; import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps'; import MainSettings from 'pages/settings/tabs/MainSettings/MainSettings'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { AppFeature } from 'state/features'; import { RootBaseStore } from 'state/rootBaseStore'; import { withMobXProviderContext } from 'state/withStore'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; import { SettingsPageTab } from './SettingsPage.types'; import CloudPage from './tabs/Cloud/CloudPage'; @@ -50,13 +50,10 @@ class SettingsPage extends React.Component this.setState({ activeTab: tab }); }; - const grafanaUser = window.grafanaBootData.user; const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings); const hasCloudPage = store.hasFeature(AppFeature.CloudConnection); - const showCloudPage = - hasCloudPage && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true); - const showLiveSettings = - hasLiveSettings && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true); + const showCloudPage = hasCloudPage && isUserActionAllowed(UserActions.OtherSettingsWrite); + const showLiveSettings = hasLiveSettings && isUserActionAllowed(UserActions.OtherSettingsRead); if (isTopNavbar()) { return ( diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx index 3bd3e13e..392acfb3 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx @@ -16,8 +16,8 @@ import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config' import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import styles from './SlackSettings.module.css'; @@ -108,7 +108,7 @@ class SlackSettings extends Component {
- + { - + }
diff --git a/grafana-plugin/src/pages/users/Users.helpers.ts b/grafana-plugin/src/pages/users/Users.helpers.ts index bc4fd318..991434fd 100644 --- a/grafana-plugin/src/pages/users/Users.helpers.ts +++ b/grafana-plugin/src/pages/users/Users.helpers.ts @@ -1,4 +1,4 @@ -import { User as UserType, UserRole } from 'models/user/user.types'; +import { User as UserType } from 'models/user/user.types'; export const getUserRowClassNameFn = (userPkToEdit?: UserType['pk'], currentUserPk?: UserType['pk']) => { return (user: UserType) => { @@ -13,15 +13,3 @@ export const getUserRowClassNameFn = (userPkToEdit?: UserType['pk'], currentUser return ''; }; }; - -export const getRealFilters = (filters: any) => { - let realFilters = { ...filters }; - if (!realFilters.roles || !realFilters.roles.length) { - realFilters = { - ...filters, - roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER], - }; - } - - return realFilters; -}; diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index 6faa3b55..d61fee49 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -19,15 +19,15 @@ import Text from 'components/Text/Text'; import UsersFilters from 'components/UsersFilters/UsersFilters'; import UserSettings from 'containers/UserSettings/UserSettings'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { getRole } from 'models/user/user.helpers'; -import { User as UserType, UserRole } from 'models/user/user.types'; +import { User as UserType } from 'models/user/user.types'; import { pages } from 'pages'; +import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; -import { getRealFilters, getUserRowClassNameFn } from './Users.helpers'; +import { getUserRowClassNameFn } from './Users.helpers'; import styles from './Users.module.css'; @@ -43,7 +43,6 @@ interface UsersState extends PageBaseState { userPkToEdit?: UserType['pk'] | 'new'; usersFilters?: { searchTerm: string; - roles?: UserRole[]; }; } @@ -55,7 +54,6 @@ class Users extends React.Component { userPkToEdit: undefined, usersFilters: { searchTerm: '', - roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER], }, errorData: initErrorDataState(), @@ -77,18 +75,16 @@ class Users extends React.Component { const { usersFilters, page } = this.state; const { userStore } = store; - if (!store.isUserActionAllowed(UserAction.ViewOtherUsers)) { + if (!isUserActionAllowed(UserActions.UserSettingsWrite)) { return; } LocationHelper.update({ p: page }, 'partial'); - return await userStore.updateItems(getRealFilters(usersFilters), page); + return await userStore.updateItems(usersFilters, page); }; componentDidUpdate(prevProps: UsersProps) { - const { store } = this.props; - - if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) { + if (!this.initialUsersLoaded && isUserActionAllowed(UserActions.UserSettingsWrite)) { this.updateUsers(); this.initialUsersLoaded = true; } @@ -121,7 +117,11 @@ class Users extends React.Component { render() { const { usersFilters, userPkToEdit, page, errorData } = this.state; - const { store, query } = this.props; + const { + store, + query, + query: { id }, + } = this.props; const { userStore } = store; const columns = [ @@ -131,12 +131,6 @@ class Users extends React.Component { title: 'User', render: this.renderTitle, }, - { - width: '5%', - title: 'Role', - key: 'role', - render: this.renderRole, - }, { width: '20%', title: 'Status', @@ -163,14 +157,13 @@ class Users extends React.Component { ]; const handleClear = () => - this.setState( - { usersFilters: { searchTerm: '', roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER] } }, - () => { - this.debouncedUpdateUsers(); - } - ); + this.setState({ usersFilters: { searchTerm: '' } }, () => { + this.debouncedUpdateUsers(); + }); const { count, results } = userStore.getSearchResult(); + const showMobileAppScreen: boolean = + id !== undefined && id !== 'me' && id === userStore.currentUserPk && store.hasFeature(AppFeature.MobileApp); return ( @@ -202,7 +195,7 @@ class Users extends React.Component {
- {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( + {isUserActionAllowed(UserActions.UserSettingsRead) ? ( <>
{ /> )}
- {userPkToEdit && } + {userPkToEdit && ( + + )}
)} @@ -273,10 +272,6 @@ class Users extends React.Component { ); }; - renderRole = (user: UserType) => { - return getRole(user.role); - }; - renderNotificationsChain = (user: UserType) => { return user.notification_chain_verbal.default; }; @@ -299,11 +294,11 @@ class Users extends React.Component { const { userStore } = store; const isCurrent = userStore.currentUserPk === user.pk; - const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const action = isCurrent ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin; return ( - +
); diff --git a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx index 30aa83ed..2a3a7f87 100644 --- a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx +++ b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx @@ -61,8 +61,7 @@ describe('PluginSetup', () => { const mockedSetupPlugin = await createComponentAndMakeAssertions(rootBaseStore); - const user = userEvent.setup(); - await user.click(screen.getByText('Retry')); + await userEvent.click(screen.getByText('Retry')); expect(mockedSetupPlugin).toHaveBeenCalledTimes(2); }); diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts index accde49d..405d9913 100644 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -50,6 +50,10 @@ describe('PluginState.generateUnknownErrorMsg', () => { }); describe('PluginState.getHumanReadableErrorFromOnCallError', () => { + beforeEach(() => { + console.warn = () => {}; + }); + test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => { expect( PluginState.getHumanReadableErrorFromOnCallError( diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 17f2f48e..6b069ca7 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -1,5 +1,3 @@ -import { OrgRole } from '@grafana/data'; -import { contextSrv } from 'grafana/app/core/core'; import { action, observable } from 'mobx'; import moment from 'moment-timezone'; import qs from 'query-string'; @@ -29,10 +27,9 @@ import { Timezone } from 'models/timezone/timezone.types'; import { UserStore } from 'models/user/user'; import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; -import { NavMenuItem } from 'pages/routes'; import { AppFeature } from 'state/features'; import PluginState from 'state/plugin'; -import { UserAction } from 'state/userAction'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; // ------ Dashboard ------ // @@ -75,9 +72,6 @@ export class RootBaseStore { @observable onCallApiUrl: string; - @observable - navMenuItem: NavMenuItem; - // -------------------------- userStore: UserStore = new UserStore(this); @@ -164,8 +158,10 @@ export class RootBaseStore { return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); } - if (!contextSrv.hasRole(OrgRole.Admin)) { - return this.setupPluginError('🚫 Admin must sign on to setup OnCall before a Viewer can use it'); + if (!isUserActionAllowed(UserActions.PluginsInstall)) { + return this.setupPluginError( + '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' + ); } try { @@ -199,10 +195,6 @@ export class RootBaseStore { this.appLoading = false; } - isUserActionAllowed(action: UserAction) { - return this.userStore.currentUser && this.userStore.currentUser.permissions.includes(action); - } - hasFeature(feature: string | AppFeature) { // todo use AppFeature only return this.features?.[feature]; diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts index 3cbc51e2..2c726d54 100644 --- a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -1,14 +1,16 @@ -import { OrgRole } from '@grafana/data'; -import { contextSrv as contextSrvOriginal } from 'grafana/app/core/core'; import { OnCallAppPluginMeta } from 'types'; import PluginState from 'state/plugin'; +import { UserActions, isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization'; import { RootBaseStore } from './'; -const contextSrv = contextSrvOriginal as { hasRole: jest.Mock> }; - jest.mock('state/plugin'); +jest.mock('utils/authorization'); + +const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock>; + +const PluginInstallAction = UserActions.PluginsInstall; const generatePluginData = ( onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null @@ -123,7 +125,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(false); + isUserActionAllowed.mockReturnValueOnce(false); PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); // test @@ -133,14 +135,14 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '🚫 Admin must sign on to setup OnCall before a Viewer can use it' + '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' ); }); @@ -160,7 +162,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(true); + isUserActionAllowed.mockReturnValueOnce(true); PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; @@ -171,8 +173,8 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -199,7 +201,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(true); + isUserActionAllowed.mockReturnValueOnce(true); PluginState.installPlugin = jest.fn().mockRejectedValueOnce(installPluginError); PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(humanReadableErrorMsg); @@ -210,8 +212,8 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); diff --git a/grafana-plugin/src/state/userAction.ts b/grafana-plugin/src/state/userAction.ts deleted file mode 100644 index d106d740..00000000 --- a/grafana-plugin/src/state/userAction.ts +++ /dev/null @@ -1,23 +0,0 @@ -export enum UserAction { - UpdateIncidents = 'update_incidents', - UpdateAlertReceiveChannels = 'update_alert_receive_channels', - UpdateEscalationPolicies = 'update_escalation_policies', - UpdateNotificationPolicies = 'update_notification_policies', - UpdateGeneralLogChannelId = 'update_general_log_channel_id', - UpdateGlobalSettings = 'update_global_settings', - UpdateOwnSettings = 'update_own_settings', - UpdateOtherUsersSettings = 'update_other_users_settings', - ViewOtherUsers = 'view_other_users', - UpdateIntegrations = 'update_integrations', - UpdateSchedules = 'update_schedules', - UpdateCustomActions = 'update_custom_actions', - UpdateApiTokens = 'update_api_tokens', - UpdateMaintenances = 'update_maintenances', - CreateTeam = 'create_team', - UpdateTeams = 'update_teams', - SendDemoAlert = 'send_demo_alert', - UpdateCurler = 'update_curler', - - // for testing purposes - Impossible = 'impossible', -} diff --git a/grafana-plugin/src/style/utils.css b/grafana-plugin/src/style/utils.css index ab2bda7f..2a35481f 100644 --- a/grafana-plugin/src/style/utils.css +++ b/grafana-plugin/src/style/utils.css @@ -1,12 +1,3 @@ -.u-flex { - display: flex; - flex-direction: row; -} - -.u-align-items-center { - align-items: center; -} - .u-position-relative { position: relative; } @@ -18,3 +9,25 @@ .u-pull-left { margin-right: auto; } + +.u-break-word { + word-break: break-word; +} + +.u-width-100 { + width: 100%; +} + +.u-flex { + display: flex; + flex-direction: row; +} + +.u-flex-center { + justify-content: center; + align-items: center; +} + +.u-align-items-center { + align-items: center; +} diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index 8204231a..bb0f2853 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -34,6 +34,7 @@ --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); --oncall-icon-stroke-color: #fff; + --hover-selected: #f4f5f5; --background-canvas: #f4f5f5; --background-primary: #fff; --background-secondary: #f4f5f5; diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 33e14637..b418f69d 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -1,4 +1,4 @@ -import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; +import { AppRootProps as BaseAppRootProps, AppPluginMeta, CurrentUserDTO, PluginConfigPageProps } from '@grafana/data'; export type OnCallPluginMetaJSONData = { stackId: number; @@ -21,7 +21,10 @@ export type OnCallPluginConfigPageProps = PluginConfigPageProps ({ + contextSrv: { + user: { + orgRole: null, + }, + hasAccess: (_action, _fallback): boolean => null, + }, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + accessControlOnCall: true, + }, + }, +})); + +describe('userHasMinimumRequiredRole', () => { + test.each([ + [OrgRole.Admin, OrgRole.Viewer, false], + [OrgRole.Admin, OrgRole.Editor, false], + [OrgRole.Admin, OrgRole.Admin, true], + [OrgRole.Editor, OrgRole.Viewer, false], + [OrgRole.Editor, OrgRole.Editor, true], + [OrgRole.Editor, OrgRole.Admin, true], + [OrgRole.Viewer, OrgRole.Viewer, true], + [OrgRole.Viewer, OrgRole.Editor, true], + [OrgRole.Viewer, OrgRole.Admin, true], + ])('Required role: %s Current role: %s', (requiredRole, mockCurrentRole, expected) => { + contextSrv.user.orgRole = mockCurrentRole; + expect(auth.userHasMinimumRequiredRole(requiredRole)).toBe(expected); + }); +}); + +describe('isUserActionAllowed', () => { + test('if RBAC is supported by the frontend, it uses the RBAC permission', () => { + // mocks + const permission = 'potato'; + contextSrv.user.permissions = { + [permission]: true, + }; + config.featureToggles.accessControlOnCall = true; + + // test + assertions + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Viewer })).toEqual(true); + }); + + test('if RBAC is not supported by the frontend, it uses the fallback role', () => { + // mocks + const permission = 'potato'; + contextSrv.user.orgRole = OrgRole.Editor; + config.featureToggles.accessControlOnCall = false; + + // test + assertions + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Viewer })).toEqual(true); + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Admin })).toEqual(false); + }); +}); + +describe('generatePermissionString', () => { + test('it properly builds permission strings with prefixes', () => { + expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual( + 'grafana-oncall-app.api-keys:read' + ); + }); + + test('it properly builds permission strings without prefixes', () => { + expect(auth.generatePermissionString(auth.Resource.TEAMS, auth.Action.READ, false)).toEqual('teams:read'); + }); +}); diff --git a/grafana-plugin/src/utils/authorization/index.ts b/grafana-plugin/src/utils/authorization/index.ts new file mode 100644 index 00000000..a5709e7e --- /dev/null +++ b/grafana-plugin/src/utils/authorization/index.ts @@ -0,0 +1,156 @@ +import { OrgRole } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { contextSrv } from 'grafana/app/core/core'; + +const ONCALL_PERMISSION_PREFIX = 'grafana-oncall-app'; + +export type UserAction = { + permission: string; + fallbackMinimumRoleRequired: OrgRole; +}; + +export enum Resource { + ALERT_GROUPS = 'alert-groups', + INTEGRATIONS = 'integrations', + ESCALATION_CHAINS = 'escalation-chains', + SCHEDULES = 'schedules', + CHATOPS = 'chatops', + OUTGOING_WEBHOOKS = 'outgoing-webhooks', + MAINTENANCE = 'maintenance', + API_KEYS = 'api-keys', + NOTIFICATIONS = 'notifications', + + NOTIFICATION_SETTINGS = 'notification-settings', + USER_SETTINGS = 'user-settings', + OTHER_SETTINGS = 'other-settings', + + TEAMS = 'teams', + PLUGINS = 'plugins', +} + +export enum Action { + READ = 'read', + WRITE = 'write', + ADMIN = 'admin', + TEST = 'test', + EXPORT = 'export', + UPDATE_SETTINGS = 'update-settings', + INSTALL = 'install', +} + +type Actions = + | 'AlertGroupsRead' + | 'AlertGroupsWrite' + | 'IntegrationsRead' + | 'IntegrationsWrite' + | 'IntegrationsTest' + | 'EscalationChainsRead' + | 'EscalationChainsWrite' + | 'SchedulesRead' + | 'SchedulesWrite' + | 'SchedulesExport' + | 'ChatOpsRead' + | 'ChatOpsWrite' + | 'ChatOpsUpdateSettings' + | 'OutgoingWebhooksRead' + | 'OutgoingWebhooksWrite' + | 'MaintenanceRead' + | 'MaintenanceWrite' + | 'APIKeysRead' + | 'APIKeysWrite' + | 'NotificationsRead' + | 'NotificationSettingsRead' + | 'NotificationSettingsWrite' + | 'UserSettingsRead' + | 'UserSettingsWrite' + | 'UserSettingsAdmin' + | 'OtherSettingsRead' + | 'OtherSettingsWrite' + | 'TeamsWrite' + | 'PluginsInstall'; + +const roleMapping: Record = { + [OrgRole.Admin]: 0, + [OrgRole.Editor]: 1, + [OrgRole.Viewer]: 2, +}; + +/** + * The logic here is: + * - an Admin should be able to do everything (including whatever an Editor and Viewer can do) + * - an Editor should be able to do things Editors and Viewers can do + * - a Viewer is only allowed to do things Viewers can do + */ +export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolean => + roleMapping[contextSrv.user.orgRole] <= roleMapping[minimumRoleRequired]; + +/** + * See here for more info on the hasAccess method + * https://github.com/grafana/grafana/blob/main/public/app/core/services/context_srv.ts#L165-L170 + * + * As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles + */ +export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => { + if (config.featureToggles.accessControlOnCall) { + return !!contextSrv.user.permissions?.[permission]; + } + return userHasMinimumRequiredRole(fallbackMinimumRoleRequired); +}; + +export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string => + `${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`; + +const constructAction = ( + resource: Resource, + action: Action, + fallbackMinimumRoleRequired: OrgRole, + includePrefix = true +): UserAction => ({ + permission: generatePermissionString(resource, action, includePrefix), + fallbackMinimumRoleRequired, +}); + +export const UserActions: { [action in Actions]: UserAction } = { + AlertGroupsRead: constructAction(Resource.ALERT_GROUPS, Action.READ, OrgRole.Viewer), + AlertGroupsWrite: constructAction(Resource.ALERT_GROUPS, Action.WRITE, OrgRole.Editor), + + IntegrationsRead: constructAction(Resource.INTEGRATIONS, Action.READ, OrgRole.Viewer), + IntegrationsWrite: constructAction(Resource.INTEGRATIONS, Action.WRITE, OrgRole.Admin), + IntegrationsTest: constructAction(Resource.INTEGRATIONS, Action.TEST, OrgRole.Editor), + + EscalationChainsRead: constructAction(Resource.ESCALATION_CHAINS, Action.READ, OrgRole.Viewer), + EscalationChainsWrite: constructAction(Resource.ESCALATION_CHAINS, Action.WRITE, OrgRole.Admin), + + SchedulesRead: constructAction(Resource.SCHEDULES, Action.READ, OrgRole.Viewer), + SchedulesWrite: constructAction(Resource.SCHEDULES, Action.WRITE, OrgRole.Admin), + SchedulesExport: constructAction(Resource.SCHEDULES, Action.WRITE, OrgRole.Editor), + + ChatOpsRead: constructAction(Resource.CHATOPS, Action.READ, OrgRole.Viewer), + ChatOpsWrite: constructAction(Resource.CHATOPS, Action.WRITE, OrgRole.Editor), + ChatOpsUpdateSettings: constructAction(Resource.CHATOPS, Action.UPDATE_SETTINGS, OrgRole.Admin), + + OutgoingWebhooksRead: constructAction(Resource.OUTGOING_WEBHOOKS, Action.READ, OrgRole.Viewer), + OutgoingWebhooksWrite: constructAction(Resource.OUTGOING_WEBHOOKS, Action.WRITE, OrgRole.Admin), + + MaintenanceRead: constructAction(Resource.MAINTENANCE, Action.READ, OrgRole.Viewer), + MaintenanceWrite: constructAction(Resource.MAINTENANCE, Action.WRITE, OrgRole.Editor), + + APIKeysRead: constructAction(Resource.API_KEYS, Action.READ, OrgRole.Admin), + APIKeysWrite: constructAction(Resource.API_KEYS, Action.WRITE, OrgRole.Admin), + + NotificationsRead: constructAction(Resource.NOTIFICATIONS, Action.READ, OrgRole.Editor), + + NotificationSettingsRead: constructAction(Resource.NOTIFICATION_SETTINGS, Action.READ, OrgRole.Viewer), + NotificationSettingsWrite: constructAction(Resource.NOTIFICATION_SETTINGS, Action.WRITE, OrgRole.Editor), + + UserSettingsRead: constructAction(Resource.USER_SETTINGS, Action.READ, OrgRole.Viewer), + UserSettingsWrite: constructAction(Resource.USER_SETTINGS, Action.WRITE, OrgRole.Editor), + UserSettingsAdmin: constructAction(Resource.USER_SETTINGS, Action.ADMIN, OrgRole.Admin), + + OtherSettingsRead: constructAction(Resource.OTHER_SETTINGS, Action.READ, OrgRole.Viewer), + OtherSettingsWrite: constructAction(Resource.OTHER_SETTINGS, Action.WRITE, OrgRole.Admin), + + // These are not oncall specific + TeamsWrite: constructAction(Resource.TEAMS, Action.WRITE, OrgRole.Admin, false), + PluginsInstall: constructAction(Resource.PLUGINS, Action.INSTALL, OrgRole.Admin, false), +}; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index a11f7b61..b09206b0 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1141,7 +1141,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.10" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== @@ -1192,6 +1192,11 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.0.tgz#fe364f025ba74f6de6c837a84ef44bdb1d61e68f" integrity sha512-mgmE7XBYY/21erpzhexk4Cj1cyTQ9LzvnTxtzM17BJ7ERMNE6W72mQRo0I1Ud8eFJ+RVVIcBNhLFZ3GX4XFz5w== +"@braintree/sanitize-url@6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.1.tgz#45ff061b9ded1c6e4474b33b336ebb1b986b825a" + integrity sha512-zr9Qs9KFQiEvMWdZesjcmRJlUck5NR+eKGS1uyKk+oYTWwlYrsoPEi6VmG6/TzBD1hKCGEimrhTgGS6hvn/xIQ== + "@csstools/postcss-color-function@^1.0.3": version "1.1.1" resolved "https://registry.yarnpkg.com/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz#2bd36ab34f82d0497cfacdc9b18d34b5e6f64b6b" @@ -1286,6 +1291,17 @@ "@emotion/weak-memoize" "^0.3.0" stylis "4.1.3" +"@emotion/css@11.10.5": + version "11.10.5" + resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.10.5.tgz#ca01bb83ce60517bc3a5c01d27ccf552fed84d9d" + integrity sha512-maJy0wG82hWsiwfJpc3WrYsyVwUbdu+sdIseKUB+/OLjB8zgc3tqkT6eO0Yt0AhIkJwGGnmMY/xmQwEAgQ4JHA== + dependencies: + "@emotion/babel-plugin" "^11.10.5" + "@emotion/cache" "^11.10.5" + "@emotion/serialize" "^1.1.1" + "@emotion/sheet" "^1.2.1" + "@emotion/utils" "^1.2.0" + "@emotion/css@11.9.0": version "11.9.0" resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.9.0.tgz#d5aeaca5ed19fc61cbdc9e032ad0b32fa6e366be" @@ -1307,20 +1323,7 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.0.tgz#f580f9beb67176fa57aae70b08ed510e1b18980f" integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== -"@emotion/react@11.9.3": - version "11.9.3" - resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.9.3.tgz#f4f4f34444f6654a2e550f5dab4f2d360c101df9" - integrity sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ== - dependencies: - "@babel/runtime" "^7.13.10" - "@emotion/babel-plugin" "^11.7.1" - "@emotion/cache" "^11.9.3" - "@emotion/serialize" "^1.0.4" - "@emotion/utils" "^1.1.0" - "@emotion/weak-memoize" "^0.2.5" - hoist-non-react-statics "^3.3.1" - -"@emotion/react@^11.8.1": +"@emotion/react@11.10.5", "@emotion/react@^11.8.1": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.10.5.tgz#95fff612a5de1efa9c0d535384d3cfa115fe175d" integrity sha512-TZs6235tCJ/7iF6/rvTaOH4oxQg2gMAcdHemjwLKIjKz4rRuYe1HJ2TQJKnAcRAfOUDdU8XoDadCe1rl72iv8A== @@ -1334,6 +1337,19 @@ "@emotion/weak-memoize" "^0.3.0" hoist-non-react-statics "^3.3.1" +"@emotion/react@11.9.3": + version "11.9.3" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.9.3.tgz#f4f4f34444f6654a2e550f5dab4f2d360c101df9" + integrity sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@emotion/babel-plugin" "^11.7.1" + "@emotion/cache" "^11.9.3" + "@emotion/serialize" "^1.0.4" + "@emotion/utils" "^1.1.0" + "@emotion/weak-memoize" "^0.2.5" + hoist-non-react-statics "^3.3.1" + "@emotion/serialize@^1.0.3", "@emotion/serialize@^1.0.4", "@emotion/serialize@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.1.tgz#0595701b1902feded8a96d293b26be3f5c1a5cf0" @@ -1408,6 +1424,18 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.2.tgz#d06a66d3ad8214186eda2432ac8b8d81868a571f" + integrity sha512-Skfy0YS3NJ5nV9us0uuPN0HDk1Q4edljaOhRBJGDWs9EBa7ZVMYBHRFlhLvvmwEoaIM9BlH6QJFn9/uZg0bACg== + +"@floating-ui/dom@^1.0.1": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.6.tgz#e42393ec381a4fe96673fbcee137a95e86c93ebc" + integrity sha512-kt/tg1oip9OAH1xjCTcx1OpcUpu9rjDw3GKJ/rEhUqhO7QyJWfrHU0DpLTNsH67+JyFL5Kv9X1utsXwKFVtyEQ== + dependencies: + "@floating-ui/core" "^1.0.2" + "@formatjs/ecma402-abstract@1.13.0": version "1.13.0" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.13.0.tgz#df6db3cbee0182bbd2fd6217103781c802aee819" @@ -1447,25 +1475,6 @@ dependencies: tslib "2.4.0" -"@grafana/agent-core@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@grafana/agent-core/-/agent-core-0.4.0.tgz#0252a888ab16dea82d97c571ca765383a1d6b319" - integrity sha512-yFbTRWVZKwUTdZ3A1AAzinWhkY0UkmduOEmlr0EYT5DJUOS/vEnzev5oB3Mh00bUUvN+AUvlMx4Nvnju1ahmJg== - dependencies: - "@opentelemetry/api" "^1.1.0" - "@opentelemetry/api-metrics" "^0.29.1" - "@opentelemetry/otlp-transformer" "^0.29.1" - uuid "^8.3.2" - -"@grafana/agent-web@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@grafana/agent-web/-/agent-web-0.4.0.tgz#03c4da34e29b4ca9f40c3574b2e85a7127a070fd" - integrity sha512-rVjLmQ/+Q8j3klDVlgt2pb3fIeWMvn3UAQLSBTC0L53Z/snNGvKQBe8b14ndjO6+cxWXFMc2kMJpw6NxpSYL5Q== - dependencies: - "@grafana/agent-core" "^0.4.0" - ua-parser-js "^1.0.2" - web-vitals "^2.1.4" - "@grafana/data@9.2.4", "@grafana/data@^9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.4.tgz#38067f207006c07754c3ed5a8835dc1909df7e2d" @@ -1518,6 +1527,32 @@ uplot "1.6.22" xss "1.0.13" +"@grafana/data@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.3.0-beta1.tgz#0c1d8da18b8f9a5c7e77312a1b36daf394bfc596" + integrity sha512-36ozpmsPjSW+yA/QFIX5j63aRV2sxQwros73YStV7x3aGM2P/vaxKm/Zux84uJxihwA9Ao8bf6RXTP3GBKcvSg== + dependencies: + "@braintree/sanitize-url" "6.0.1" + "@grafana/schema" "9.3.0-beta1" + "@types/d3-interpolate" "^1.4.0" + d3-interpolate "1.4.0" + date-fns "2.29.3" + eventemitter3 "4.0.7" + fast_array_intersect "1.1.0" + history "4.10.1" + lodash "4.17.21" + marked "4.2.0" + moment "2.29.4" + moment-timezone "0.5.38" + ol "7.1.0" + papaparse "5.3.2" + regenerator-runtime "0.13.10" + rxjs "7.5.7" + tinycolor2 "1.4.2" + tslib "2.4.1" + uplot "1.6.22" + xss "1.0.14" + "@grafana/e2e-selectors@9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.2.4.tgz#748539cc0313ee1c23055a100313235ef2fca64b" @@ -1536,6 +1571,15 @@ tslib "2.4.0" typescript "4.8.2" +"@grafana/e2e-selectors@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.3.0-beta1.tgz#49ca6a4957763a8fee8560a5cd7f546a3f4853d3" + integrity sha512-0uG9eltmh/FPLk32+pfpw4Vz8WQNuVOy/E4pnIh2Wv9BlqHWxrABX7o6YlXzlCQMv8mxhCcey/OxJHC4AZxPzA== + dependencies: + "@grafana/tsconfig" "^1.2.0-rc1" + tslib "2.4.1" + typescript "4.8.4" + "@grafana/eslint-config@5.0.0", "@grafana/eslint-config@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-5.0.0.tgz#e08a89d378772340bc6cd1872ec4d15666269aba" @@ -1550,21 +1594,40 @@ eslint-plugin-react-hooks "4.3.0" typescript "4.6.4" -"@grafana/runtime@^9.2.4": - version "9.2.4" - resolved "https://registry.yarnpkg.com/@grafana/runtime/-/runtime-9.2.4.tgz#f3d1a4e2ee51fed76ac31a37422e5978e3ff57a9" - integrity sha512-k6YLPBB8waRe5SqzwmhxwzEYduY3GvBsZTbERIwf+8/ep7kfRnqAhbbFFUujQU4+pgBd3N6HtXBg9KKB6eLqaA== +"@grafana/faro-core@^1.0.0-beta2": + version "1.0.0-beta2" + resolved "https://registry.yarnpkg.com/@grafana/faro-core/-/faro-core-1.0.0-beta2.tgz#97636677c1d687b0b238642a3978334652f263a5" + integrity sha512-htw6qrl4EsjxUrIugd+85H8voIxm+Vs8uOl4gGhsscb1/nUJoqTZmegUTXR+sYGyWZdHztoGV+rm5yerWrKCbQ== dependencies: - "@grafana/agent-web" "^0.4.0" - "@grafana/data" "9.2.4" - "@grafana/e2e-selectors" "9.2.4" - "@grafana/ui" "9.2.4" + "@opentelemetry/api" "^1.1.0" + "@opentelemetry/api-metrics" "^0.33.0" + "@opentelemetry/otlp-transformer" "^0.33.0" + fast-deep-equal "^3.1.3" + +"@grafana/faro-web-sdk@1.0.0-beta2": + version "1.0.0-beta2" + resolved "https://registry.yarnpkg.com/@grafana/faro-web-sdk/-/faro-web-sdk-1.0.0-beta2.tgz#d096a350d6366a108428a205753c797802eb480d" + integrity sha512-Z/ZbMpBG4/+ZHuPntVTANvStBP1pkDT3+oqKDYW3O4iP4wBhIUyXk7Pmr9LJZIjcStBizEFMH/N/F/gyD5DHjQ== + dependencies: + "@grafana/faro-core" "^1.0.0-beta2" + ua-parser-js "^1.0.32" + web-vitals "^3.0.4" + +"@grafana/runtime@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/runtime/-/runtime-9.3.0-beta1.tgz#4bcd5d8c24c1e810b254f113598cbb1cb759ee16" + integrity sha512-Fd87OXQbf9IqGeOitwF8KBuyvw9Yv9VDmC30UKCvpQVtKTYoHngEYXMD1ZLUgmb4G18PYDsBqYfth4InfPAlSQ== + dependencies: + "@grafana/data" "9.3.0-beta1" + "@grafana/e2e-selectors" "9.3.0-beta1" + "@grafana/faro-web-sdk" "1.0.0-beta2" + "@grafana/ui" "9.3.0-beta1" "@sentry/browser" "6.19.7" history "4.10.1" lodash "4.17.21" - rxjs "7.5.6" + rxjs "7.5.7" systemjs "0.20.19" - tslib "2.4.0" + tslib "2.4.1" "@grafana/schema@9.2.4": version "9.2.4" @@ -1580,6 +1643,13 @@ dependencies: tslib "2.4.0" +"@grafana/schema@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/schema/-/schema-9.3.0-beta1.tgz#0554d8a6c9de51e3f55f00da614d8c8f091980ab" + integrity sha512-/12NkJXGfbo3bWPUMsSGJXZiLOil3TX2xoiL86ssnziSdzN9b7uJ6xhdEUfZ3sdm4pXuiBq4tlJ9FUP6n6he8Q== + dependencies: + tslib "2.4.1" + "@grafana/toolkit@^9.2.4": version "9.2.6" resolved "https://registry.yarnpkg.com/@grafana/toolkit/-/toolkit-9.2.6.tgz#55d424321a65a027f3365c6e0df649bcc1d2c9d6" @@ -1675,16 +1745,16 @@ resolved "https://registry.yarnpkg.com/@grafana/tsconfig/-/tsconfig-1.2.0-rc1.tgz#10973c978ec95b0ea637511254b5f478bce04de7" integrity sha512-+SgQeBQ1pT6D/E3/dEdADqTrlgdIGuexUZ8EU+8KxQFKUeFeU7/3z/ayI2q/wpJ/Kr6WxBBNlrST6aOKia19Ag== -"@grafana/ui@9.2.4", "@grafana/ui@^9.2.4": - version "9.2.4" - resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.4.tgz#885b0f10bd700aa0dc094f2fcb554477fc47e410" - integrity sha512-V9sNQwcAkMAmWjM/DLMw9X+J0nqBmrwNV1uJ1kyS+3cRRwCNyJsZUz3NuOnzCbvCEl3bopLyY/WBSHONbLEoig== +"@grafana/ui@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.6.tgz#1974edb48b5873c257278886b65ccb1d7e45a33a" + integrity sha512-NM+tNbpks218QHQo9hywr/00fMOjQt0shf3Sq2ywr8e172PXuPxh/AuTACKuVFgW2FY1z5StFUc2B40Dgvn0JQ== dependencies: "@emotion/css" "11.9.0" "@emotion/react" "11.9.3" - "@grafana/data" "9.2.4" - "@grafana/e2e-selectors" "9.2.4" - "@grafana/schema" "9.2.4" + "@grafana/data" "9.2.6" + "@grafana/e2e-selectors" "9.2.6" + "@grafana/schema" "9.2.6" "@monaco-editor/react" "4.4.5" "@popperjs/core" "2.11.5" "@react-aria/button" "3.6.1" @@ -1741,16 +1811,86 @@ uplot "1.6.22" uuid "8.3.2" -"@grafana/ui@9.2.6": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.6.tgz#1974edb48b5873c257278886b65ccb1d7e45a33a" - integrity sha512-NM+tNbpks218QHQo9hywr/00fMOjQt0shf3Sq2ywr8e172PXuPxh/AuTACKuVFgW2FY1z5StFUc2B40Dgvn0JQ== +"@grafana/ui@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.3.0-beta1.tgz#941db8fab3f570e1639257311c514cd6708fb297" + integrity sha512-40bQV7gHqONb18G7MmhueuvJcX+DGJYeKTiexZ+wLEW46/74iBIhRI5RyDQsqFntnZpOeVZuQQODWPlZZ7lYpw== + dependencies: + "@emotion/css" "11.10.5" + "@emotion/react" "11.10.5" + "@grafana/data" "9.3.0-beta1" + "@grafana/e2e-selectors" "9.3.0-beta1" + "@grafana/schema" "9.3.0-beta1" + "@leeoniya/ufuzzy" "0.8.0" + "@monaco-editor/react" "4.4.6" + "@popperjs/core" "2.11.6" + "@react-aria/button" "3.6.1" + "@react-aria/dialog" "3.3.1" + "@react-aria/focus" "3.8.0" + "@react-aria/menu" "3.6.1" + "@react-aria/overlays" "3.10.1" + "@react-aria/utils" "3.13.1" + "@react-stately/menu" "3.4.1" + "@sentry/browser" "6.19.7" + ansicolor "1.1.100" + calculate-size "1.1.1" + classnames "2.3.2" + core-js "3.26.0" + d3 "5.15.0" + date-fns "2.29.3" + hoist-non-react-statics "3.3.2" + i18next "^22.0.0" + immutable "4.1.0" + is-hotkey "0.2.0" + jquery "3.6.1" + lodash "4.17.21" + memoize-one "6.0.0" + moment "2.29.4" + monaco-editor "0.34.0" + ol "7.1.0" + prismjs "1.29.0" + rc-cascader "3.7.0" + rc-drawer "4.4.3" + rc-slider "10.0.1" + rc-time-picker "^3.7.3" + rc-tooltip "5.2.2" + react-beautiful-dnd "13.1.1" + react-calendar "3.9.0" + react-colorful "5.6.1" + react-custom-scrollbars-2 "4.5.0" + react-dropzone "14.2.3" + react-highlight-words "0.18.0" + react-hook-form "7.5.3" + react-i18next "^12.0.0" + react-inlinesvg "3.0.1" + react-popper "2.3.0" + react-popper-tooltip "^4.3.1" + react-router-dom "^5.2.0" + react-select "5.6.0" + react-select-event "^5.1.0" + react-table "7.8.0" + react-transition-group "4.4.5" + react-use "17.4.0" + react-window "1.8.8" + rxjs "7.5.7" + slate "0.47.9" + slate-plain-serializer "0.7.13" + slate-react "0.22.10" + tinycolor2 "1.4.2" + tslib "2.4.1" + uplot "1.6.22" + uuid "9.0.0" + +"@grafana/ui@^9.2.4": + version "9.2.4" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.4.tgz#885b0f10bd700aa0dc094f2fcb554477fc47e410" + integrity sha512-V9sNQwcAkMAmWjM/DLMw9X+J0nqBmrwNV1uJ1kyS+3cRRwCNyJsZUz3NuOnzCbvCEl3bopLyY/WBSHONbLEoig== dependencies: "@emotion/css" "11.9.0" "@emotion/react" "11.9.3" - "@grafana/data" "9.2.6" - "@grafana/e2e-selectors" "9.2.6" - "@grafana/schema" "9.2.6" + "@grafana/data" "9.2.4" + "@grafana/e2e-selectors" "9.2.4" + "@grafana/schema" "9.2.4" "@monaco-editor/react" "4.4.5" "@popperjs/core" "2.11.5" "@react-aria/button" "3.6.1" @@ -2135,6 +2275,11 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@leeoniya/ufuzzy@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-0.8.0.tgz#2ccfc29453e168ce5866bf6dee89771db404a7f7" + integrity sha512-EOc0fEsIqe6CDZxC14efhybnPcXyJi7VaZby40mWASZD0CI78ONoF+4+LGlcT58jsAIwEims5ARbRqo+BVHEAQ== + "@mapbox/jsonlint-lines-primitives@~2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" @@ -2179,6 +2324,14 @@ "@monaco-editor/loader" "^1.3.2" prop-types "^15.7.2" +"@monaco-editor/react@4.4.6": + version "4.4.6" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.4.6.tgz#8ae500b0edf85276d860ed702e7056c316548218" + integrity sha512-Gr3uz3LYf33wlFE3eRnta4RxP5FSNxiIV9ENn2D2/rN8KgGAD8ecvcITRtsbbyuOuNkwbuHYxfeaz2Vr+CtyFA== + dependencies: + "@monaco-editor/loader" "^1.3.2" + prop-types "^15.7.2" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2200,10 +2353,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@opentelemetry/api-metrics@0.29.2", "@opentelemetry/api-metrics@^0.29.1": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.29.2.tgz#daa823e0965754222b49a6ae6133df8b39ff8fd2" - integrity sha512-yRdF5beqKuEdsPNoO7ijWCQ9HcyN0Tlgicf8RS6gzGOI54d6Hj7yKquJ6+X9XV+CSRbRWJYb+lOsXyso7uyX2g== +"@opentelemetry/api-metrics@0.33.0", "@opentelemetry/api-metrics@^0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.33.0.tgz#753d355289b7811ad254d6e5b0193bd1b9f23ab0" + integrity sha512-78evfPRRRnJA6uZ3xuBuS3VZlXTO/LRs+Ff1iv3O/7DgibCtq9k27T6Zlj8yRdJDFmcjcbQrvC0/CpDpWHaZYA== dependencies: "@opentelemetry/api" "^1.0.0" @@ -2212,55 +2365,55 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.3.0.tgz#27c6f776ac3c1c616651e506a89f438a0ed6a055" integrity sha512-YveTnGNsFFixTKJz09Oi4zYkiLT5af3WpZDu4aIUM7xX+2bHAkOJayFTVQd6zB8kkWPpbua4Ha6Ql00grdLlJQ== -"@opentelemetry/core@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.3.1.tgz#6eef5c5efca9a4cd7daa0cd4c7ff28ca2317c8d7" - integrity sha512-k7lOC86N7WIyUZsUuSKZfFIrUtINtlauMGQsC1r7jNmcr0vVJGqK1ROBvt7WWMxLbpMnt1q2pXJO8tKu0b9auA== +"@opentelemetry/core@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.7.0.tgz#83bdd1b7a4ceafcdffd6590420657caec5f7b34c" + integrity sha512-AVqAi5uc8DrKJBimCTFUT4iFI+5eXpo4sYmGbQ0CypG0piOTHE2g9c5aSoTGYXu3CzOmJZf7pT6Xh+nwm5d6yQ== dependencies: - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/otlp-transformer@^0.29.1": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.29.2.tgz#61897d3d747182ab7e315a88a9a710a759c13390" - integrity sha512-Y6dJj+rhRGynxhLlgEJkdkXuLHdFG8igcSBv6oy3m3GHSSvZkyNV34dVjtZJ586mUXsbFuAf6uqjzteobewO1g== +"@opentelemetry/otlp-transformer@^0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.33.0.tgz#6fd3ddc944f017da08d445f142cad1779770e0e0" + integrity sha512-L4OpsUaki9/Fib17t44YkDvAz3RpMZTtl6hYBhcTqAnqY0wVBpQf0ra25GyHQTKj+oiA//ZxvOlmmM/dXCYxoQ== dependencies: - "@opentelemetry/api-metrics" "0.29.2" - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" - "@opentelemetry/sdk-metrics-base" "0.29.2" - "@opentelemetry/sdk-trace-base" "1.3.1" + "@opentelemetry/api-metrics" "0.33.0" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" + "@opentelemetry/sdk-metrics" "0.33.0" + "@opentelemetry/sdk-trace-base" "1.7.0" -"@opentelemetry/resources@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.3.1.tgz#9fd85ac4ffeefc35441404b384d5c1db8b243121" - integrity sha512-X8bl3X0YjlsHWy0Iv0KUETtZuRUznX4yr1iScKCtfy8AoRfZFc2xxWKMDJ0TrqYwSapgeg4YwpmRzUKmmnrbeA== +"@opentelemetry/resources@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.7.0.tgz#90ccd3a6a86b4dfba4e833e73944bd64958d78c5" + integrity sha512-u1M0yZotkjyKx8dj+46Sg5thwtOTBmtRieNXqdCRiWUp6SfFiIP0bI+1XK3LhuXqXkBXA1awJZaTqKduNMStRg== dependencies: - "@opentelemetry/core" "1.3.1" - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/sdk-metrics-base@0.29.2": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics-base/-/sdk-metrics-base-0.29.2.tgz#bd515455f1d90e211458dcf957f0ae937772b155" - integrity sha512-7hhhZ/6YRRgAXOUTeCsbe6SIk3wZAdAHnEwGGp7aiVH5AOyioHyHInw4EHtowlD6dbLxUWURjh6k+Geht2zbxg== +"@opentelemetry/sdk-metrics@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-0.33.0.tgz#c4e51decc6e3bb0e1e97c7b081955d357e46c2fe" + integrity sha512-ZXPixOlTd/FHLwpkmm5nTpJE7bZOPfmbSz8hBVFCEHkXE1aKEKaM38UFnZ+2xzOY1tDsDwyxEiiBiDX8y3039A== dependencies: - "@opentelemetry/api-metrics" "0.29.2" - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" + "@opentelemetry/api-metrics" "0.33.0" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" lodash.merge "4.6.2" -"@opentelemetry/sdk-trace-base@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.3.1.tgz#958083dbab928eefd17848959ac8810c787bec7f" - integrity sha512-Or95QZ+9QyvAiwqj+K68z8bDDuyWF50c37w17D10GV1dWzg4Ezcectsu/GB61QcBxm3Y4br0EN5F5TpIFfFliQ== +"@opentelemetry/sdk-trace-base@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.7.0.tgz#b498424e0c6340a9d80de63fd408c5c2130a60a5" + integrity sha512-Iz84C+FVOskmauh9FNnj4+VrA+hG5o+tkMzXuoesvSfunVSioXib0syVFeNXwOm4+M5GdWCuW632LVjqEXStIg== dependencies: - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/semantic-conventions@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.3.1.tgz#ba07b864a3c955f061aa30ea3ef7f4ae4449794a" - integrity sha512-wU5J8rUoo32oSef/rFpOT1HIjLjAv3qIDHkw1QIhODV3OpAVHi5oVzlouozg9obUmZKtbZ0qUe/m7FP0y0yBzA== +"@opentelemetry/semantic-conventions@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.7.0.tgz#af80a1ef7cf110ea3a68242acd95648991bcd763" + integrity sha512-FGBx/Qd09lMaqQcogCHyYrFEpTx4cAjeS+48lMIR12z7LdH+zofGDVQSubN59nL6IpubfKqTeIDu9rNO28iHVA== "@petamoriken/float16@^3.4.7": version "3.6.6" @@ -2277,7 +2430,7 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== -"@popperjs/core@^2.11.5": +"@popperjs/core@2.11.6", "@popperjs/core@^2.11.5": version "2.11.6" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== @@ -4616,7 +4769,7 @@ classnames@2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== -classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: +classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== @@ -4901,6 +5054,11 @@ core-js@3.25.1: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.1.tgz#5818e09de0db8956e16bf10e2a7141e931b7c69c" integrity sha512-sr0FY4lnO1hkQ4gLDr24K0DGnweGO1QwSj5BpfQjpSJPdqWalja4cTps29Y/PJVG/P7FYlPDkH3hO+Tr0CvDgQ== +core-js@3.26.0: + version "3.26.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.26.0.tgz#a516db0ed0811be10eac5d94f3b8463d03faccfe" + integrity sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw== + core-js@^2.4.0: version "2.6.12" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" @@ -5425,6 +5583,11 @@ date-fns@2.29.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.1.tgz#9667c2615525e552b5135a3116b95b1961456e60" integrity sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw== +date-fns@2.29.3: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + dayjs@^1.11.5: version "1.11.6" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.6.tgz#2e79a226314ec3ec904e3ee1dd5a4f5e5b1c7afb" @@ -5754,6 +5917,11 @@ duplexer@^0.1.2: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== +earcut@^2.2.3: + version "2.2.4" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a" + integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ== + electron-to-chromium@^1.4.251: version "1.4.284" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz#61046d1e4cab3a25238f6bf7413795270f125592" @@ -7173,6 +7341,13 @@ html-minifier-terser@^6.0.2: relateurl "^0.2.7" terser "^5.10.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-tags@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.2.0.tgz#dbb3518d20b726524e4dd43de397eb0a95726961" @@ -7263,6 +7438,13 @@ hyphenate-style-name@^1.0.0, hyphenate-style-name@^1.0.2: resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d" integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== +i18next@^22.0.0: + version "22.0.6" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.0.6.tgz#d7029912f8aa74ff295c0d9afd1b7dea45859b49" + integrity sha512-RlreNGoPIdDP4QG+qSA9PxZKGwlzmcozbI9ObI6+OyUa/Rp0EjZZA9ubyBjw887zVNZsC+7FI3sXX8oiTzAfig== + dependencies: + "@babel/runtime" "^7.17.2" + iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -8421,6 +8603,11 @@ jquery@3.6.0: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== +jquery@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.1.tgz#fab0408f8b45fc19f956205773b62b292c147a16" + integrity sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw== + js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -8931,6 +9118,11 @@ marked@4.1.0: resolved "https://registry.yarnpkg.com/marked/-/marked-4.1.0.tgz#3fc6e7485f21c1ca5d6ec4a39de820e146954796" integrity sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA== +marked@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.0.tgz#f1683b077626a6c53e28926b798a18184aa13a91" + integrity sha512-1qWHjHlBKwjnDfrkxd0L3Yx4LTad/WO7+d13YsXAC/ZfKj7p0xkLV3sDXJzfWgL7GfW4IBZwMAYWaz+ifyQouQ== + matchmediaquery@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7" @@ -8988,7 +9180,7 @@ memfs@^3.1.2, memfs@^3.4.1: dependencies: fs-monkey "^1.0.3" -memoize-one@6.0.0: +memoize-one@6.0.0, memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== @@ -9175,7 +9367,7 @@ moment-timezone@0.5.35: dependencies: moment ">= 2.9.0" -moment-timezone@^0.5.35: +moment-timezone@0.5.38, moment-timezone@^0.5.35: version "0.5.38" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.38.tgz#9674a5397b8be7c13de820fd387d8afa0f725aad" integrity sha512-nMIrzGah4+oYZPflDvLZUgoVUO4fvAqHstvG3xAUnMolWncuAiLDWNnJZj6EwJGMGfb1ZcuTFE6GI3hNOVWI/Q== @@ -9504,6 +9696,14 @@ object.values@^1.1.5: define-properties "^1.1.4" es-abstract "^1.20.4" +ol-mapbox-style@9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/ol-mapbox-style/-/ol-mapbox-style-9.1.0.tgz#1504b1a2c3cc23482c3c95cd55a1cf1d2ac8a451" + integrity sha512-R/XE6FdviaXNdnSw6ItHSEreMtQU68cwQCGv4Kl8yG0V1dZhnI5JWr8IOphJwffPVxfWTCnJb5aALGSB89MvhA== + dependencies: + "@mapbox/mapbox-gl-style-spec" "^13.23.1" + mapbox-to-css-font "^2.4.1" + ol-mapbox-style@^8.0.5: version "8.2.1" resolved "https://registry.yarnpkg.com/ol-mapbox-style/-/ol-mapbox-style-8.2.1.tgz#0f0c252b6495853a137d7e4dd3f915fab664b356" @@ -9522,6 +9722,17 @@ ol@6.15.1: pbf "3.2.1" rbush "^3.0.1" +ol@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/ol/-/ol-7.1.0.tgz#aab69a0539e59d6a4361cbc0f69f8b00c7298c9c" + integrity sha512-mAeV5Ca4mFhYaJoGWNZnIMN5VNnFTf63FgZjBiYu/DjQDGKNsD5QyvvqVziioVdOOgl6b8rPB/ypj2XNBinPwA== + dependencies: + earcut "^2.2.3" + geotiff "2.0.4" + ol-mapbox-style "9.1.0" + pbf "3.2.1" + rbush "^3.0.1" + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -10597,6 +10808,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== + query-string@*: version "7.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" @@ -10700,6 +10916,18 @@ rc-cascader@3.6.1: rc-tree "~5.6.3" rc-util "^5.6.1" +rc-cascader@3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.7.0.tgz#98134df578ce1cca22be8fb4319b04df4f3dca36" + integrity sha512-SFtGpwmYN7RaWEAGTS4Rkc62ZV/qmQGg/tajr/7mfIkleuu8ro9Hlk6J+aA0x1YS4zlaZBtTcSaXM01QMiEV/A== + dependencies: + "@babel/runtime" "^7.12.5" + array-tree-filter "^2.1.0" + classnames "^2.3.1" + rc-select "~14.1.0" + rc-tree "~5.7.0" + rc-util "^5.6.1" + rc-drawer@4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-4.4.3.tgz#2094937a844e55dc9644236a2d9fba79c344e321" @@ -10751,6 +10979,16 @@ rc-select@~14.1.0: rc-util "^5.16.1" rc-virtual-list "^3.2.0" +rc-slider@10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-10.0.1.tgz#7058c68ff1e1aa4e7c3536e5e10128bdbccb87f9" + integrity sha512-igTKF3zBet7oS/3yNiIlmU8KnZ45npmrmHlUUio8PNbIhzMcsh+oE/r2UD42Y6YD2D/s+kzCQkzQrPD6RY435Q== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.5" + rc-util "^5.18.1" + shallowequal "^1.1.0" + rc-slider@9.7.5: version "9.7.5" resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-9.7.5.tgz#193141c68e99b1dc3b746daeb6bf852946f5b7f4" @@ -10785,7 +11023,7 @@ rc-time-picker@^3.7.3: rc-trigger "^2.2.0" react-lifecycles-compat "^3.0.4" -rc-tooltip@^5.0.1: +rc-tooltip@5.2.2, rc-tooltip@^5.0.1: version "5.2.2" resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-5.2.2.tgz#e5cafa8ecebf78108936a0bcb93c150fa81ac93b" integrity sha512-jtQzU/18S6EI3lhSGoDYhPqNpWajMtS5VV/ld1LwyfrDByQpYmw/LW6U7oFXXLukjfDHQ7Ju705A82PRNFWYhg== @@ -10805,6 +11043,17 @@ rc-tree@~5.6.3: rc-util "^5.16.1" rc-virtual-list "^3.4.8" +rc-tree@~5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/rc-tree/-/rc-tree-5.7.0.tgz#d0e316eeeac2ba4a1c36b2b2201d84884f1c76a1" + integrity sha512-F+Ewkv/UcutshnVBMISP+lPdHDlcsL+YH/MQDVWbk+QdkfID7vXiwrHMEZn31+2Rbbm21z/HPceGS8PXGMmnQg== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "2.x" + rc-motion "^2.0.1" + rc-util "^5.16.1" + rc-virtual-list "^3.4.8" + rc-trigger@^2.2.0: version "2.6.5" resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-2.6.5.tgz#140a857cf28bd0fa01b9aecb1e26a50a700e9885" @@ -10840,7 +11089,7 @@ rc-util@^4.0.4, rc-util@^4.15.3, rc-util@^4.4.0: react-lifecycles-compat "^3.0.4" shallowequal "^1.1.0" -rc-util@^5.15.0, rc-util@^5.16.1, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.22.5, rc-util@^5.3.0, rc-util@^5.6.1, rc-util@^5.7.0: +rc-util@^5.15.0, rc-util@^5.16.1, rc-util@^5.18.1, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.22.5, rc-util@^5.3.0, rc-util@^5.6.1, rc-util@^5.7.0: version "5.24.4" resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.24.4.tgz#a4126f01358c86f17c1bf380a1d83d6c9155ae65" integrity sha512-2a4RQnycV9eV7lVZPEJ7QwJRPlZNc06J7CwcwZo4vIHr3PfUqtYgl1EkUV9ETAc6VRRi8XZOMFhYG63whlIC9Q== @@ -10872,6 +11121,19 @@ react-beautiful-dnd@13.1.0: redux "^4.0.4" use-memo-one "^1.1.1" +react-beautiful-dnd@13.1.1: + version "13.1.1" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" + integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-calendar@3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.7.0.tgz#951d56e91afb33b1c1e019cb790349fbffcc6894" @@ -10882,11 +11144,26 @@ react-calendar@3.7.0: merge-class-names "^1.1.1" prop-types "^15.6.0" +react-calendar@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.9.0.tgz#4dfe342ef61574c0e819e49847981076c7af58ea" + integrity sha512-g6RJCEaPovHTiV2bMhBUfm0a1YoMj4bOUpL8hQSLmR1Glhc7lgRLtZBd4mcC4jkoGsb+hv9uA/QH4pZcm5l9lQ== + dependencies: + "@wojtekmaj/date-utils" "^1.0.2" + get-user-locale "^1.2.0" + merge-class-names "^1.1.1" + prop-types "^15.6.0" + react-colorful@5.5.1: version "5.5.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== +react-colorful@5.6.1: + version "5.6.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" + integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== + react-copy-to-clipboard@^5.0.2: version "5.1.0" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" @@ -10960,6 +11237,15 @@ react-dropzone@14.2.2: file-selector "^0.6.0" prop-types "^15.8.1" +react-dropzone@14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" + integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.6.0" + prop-types "^15.8.1" + react-emoji-render@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/react-emoji-render/-/react-emoji-render-1.2.4.tgz#fa3542a692e1eed3236f0f12b8e3a61b2818e2c2" @@ -11000,6 +11286,14 @@ react-hook-form@7.5.3: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.5.3.tgz#9a624fa14ec153b154891c5ebddae02ec5c2e40f" integrity sha512-5T0mfJ4kCPKljd7t3Rgp7lML4Y2+kaZIeMdN6Zo/J7gBQ+WkrDBHOftdOtz4X+7/eqHGak5yL5evNpYdA9abVA== +react-i18next@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-12.0.0.tgz#634015a2c035779c5736ae4c2e5c34c1659753b1" + integrity sha512-/O7N6aIEAl1FaWZBNvhdIo9itvF/MO/nRKr9pYqRc9LhuC1u21SlfwpiYQqvaeNSEW3g3qUXLREOWMt+gxrWbg== + dependencies: + "@babel/runtime" "^7.14.5" + html-parse-stringify "^3.0.1" + react-immutable-proptypes@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz#cce96d68cc3c18e89617cbf3092d08e35126af4a" @@ -11015,6 +11309,14 @@ react-inlinesvg@3.0.0: exenv "^1.2.2" react-from-dom "^0.6.2" +react-inlinesvg@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/react-inlinesvg/-/react-inlinesvg-3.0.1.tgz#2133f5d2c770ac405060db2ce1c13eed30e7e83b" + integrity sha512-cBfoyfseNI2PkDA7ZKIlDoHq0eMfpoC3DhKBQNC+/X1M4ZQB+aXW+YiNPUDDDKXUsGDUIZWWiZWNFeauDIVdoA== + dependencies: + exenv "^1.2.2" + react-from-dom "^0.6.2" + react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -11062,6 +11364,14 @@ react-popper@2.3.0, react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" +react-qr-code@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.8.tgz#d34a766fb5b664a40dbdc7020f7ac801bacb2851" + integrity sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA== + dependencies: + prop-types "^15.8.1" + qr.js "0.0.0" + react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" @@ -11132,6 +11442,21 @@ react-select@5.4.0: prop-types "^15.6.0" react-transition-group "^4.3.0" +react-select@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.6.0.tgz#d987f4c86b3dcd32307a0104e503e4e8a9777a34" + integrity sha512-uUvP/72rA8NGhOL16RVBaeC12Wa4NUE0iXIa6hz0YRno9ZgxTmpuMeKzjR7vHcwmigpVCoe0prP+3NVb6Obq8Q== + dependencies: + "@babel/runtime" "^7.12.0" + "@emotion/cache" "^11.4.0" + "@emotion/react" "^11.8.1" + "@floating-ui/dom" "^1.0.1" + "@types/react-transition-group" "^4.4.0" + memoize-one "^6.0.0" + prop-types "^15.6.0" + react-transition-group "^4.3.0" + use-isomorphic-layout-effect "^1.1.2" + react-shallow-renderer@^16.13.1: version "16.15.0" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" @@ -11181,7 +11506,7 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" -react-transition-group@^4.3.0, react-transition-group@^4.4.5: +react-transition-group@4.4.5, react-transition-group@^4.3.0, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -11224,6 +11549,14 @@ react-window@1.8.7: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" +react-window@1.8.8: + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -11308,6 +11641,11 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== +regenerator-runtime@0.13.10, regenerator-runtime@^0.13.10: + version "0.13.10" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" + integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== + regenerator-runtime@0.13.9: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" @@ -11318,11 +11656,6 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.10: - version "0.13.10" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" - integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== - regenerator-transform@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" @@ -11589,6 +11922,13 @@ rxjs@7.5.6: dependencies: tslib "^2.1.0" +rxjs@7.5.7, rxjs@^7.5.1, rxjs@^7.5.5: + version "7.5.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" + integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== + dependencies: + tslib "^2.1.0" + rxjs@^6.4.0, rxjs@^6.6.0: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -11596,13 +11936,6 @@ rxjs@^6.4.0, rxjs@^6.6.0: dependencies: tslib "^1.9.0" -rxjs@^7.5.1, rxjs@^7.5.5: - version "7.5.7" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" - integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== - dependencies: - tslib "^2.1.0" - safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -11888,7 +12221,7 @@ slate-plain-serializer@0.7.11: resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.11.tgz#74ff6eb949e9fbd92ad98ed833d74d5082f2688b" integrity sha512-vzXQ68GiHHcTUcAB6ggf2qN/sX9BoLs77SMHacp5Gkg+oHAA/NxRzRH4efDAhpiJqfJZDrA3rQySK6+Y7KAuwg== -slate-plain-serializer@^0.7.11: +slate-plain-serializer@0.7.13, slate-plain-serializer@^0.7.11: version "0.7.13" resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.13.tgz#6de8f5c645dd749f1b2e4426c20de74bfd213adf" integrity sha512-TtrlaslxQBEMV0LYdf3s7VAbTxRPe1xaW10WNNGAzGA855/0RhkaHjKkQiRjHv5rvbRleVf7Nxr9fH+4uErfxQ== @@ -12778,16 +13111,16 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslib@2.4.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" - integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -12861,7 +13194,12 @@ typescript@4.8.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== -ua-parser-js@^1.0.2: +typescript@4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== + +ua-parser-js@^1.0.32: version "1.0.32" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.32.tgz#786bf17df97de159d5b1c9d5e8e9e89806f8a030" integrity sha512-dXVsz3M4j+5tTiovFVyVqssXBu5HM47//YSOeZ9fQkdDKkfzv2v3PP1jmH6FUyPW+yCSn7aBVK1fGGKNhowdDA== @@ -13027,6 +13365,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-isomorphic-layout-effect@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-memo-one@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" @@ -13057,6 +13400,11 @@ uuid@8.3.2, uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -13109,6 +13457,11 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -13152,10 +13505,10 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -web-vitals@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c" - integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg== +web-vitals@^3.0.4: + version "3.1.0" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.1.0.tgz#a6f5156cb6c7fee562da46078540265ac2cd2d16" + integrity sha512-zCeQ+bOjWjJbXv5ZL0r8Py3XP2doCQMZXNKlBGfUjPAVZWokApdeF/kFlK1peuKlCt8sL9TFkKzyXE9/cmNJQA== web-worker@^1.2.0: version "1.2.0" @@ -13366,6 +13719,14 @@ xss@1.0.13: commander "^2.20.3" cssfilter "0.0.10" +xss@1.0.14: + version "1.0.14" + resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694" + integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw== + dependencies: + commander "^2.20.3" + cssfilter "0.0.10" + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" diff --git a/helm/README.md b/helm/README.md index 05e8f471..065f411d 100644 --- a/helm/README.md +++ b/helm/README.md @@ -4,13 +4,13 @@ > Make sure ports 30001 and 30002 are free on your machine - ``` + ```bash kind create cluster --image kindest/node:v1.24.7 --config kind.yml ``` 2. Install the helm chart - ``` + ```bash helm install helm-testing \ ../oncall --wait --timeout 30m \ --wait-for-jobs \ @@ -20,13 +20,18 @@ 3. Get credentials - ``` + + + ```bash echo "\n\nOpen Grafana on localhost:30002 with credentials - user: admin, password: $(kubectl get secret --namespace default helm-testing-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo)" echo "Open Plugins -> Grafana OnCall -> fill form: backend url: localhost:30001, grafana url: localhost: 30001, token below" export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=helm-testing,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}") ``` + + 4. Clean up - ``` + + ```bash kind delete cluster ``` diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index cb97e459..70f7df3c 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -2,25 +2,23 @@ apiVersion: v2 name: oncall description: Developer-friendly incident response with brilliant Slack integration - type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.12 +version: 1.1.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v1.1.0" +appVersion: "v1.1.5" dependencies: - name: cert-manager version: v1.8.0 repository: https://charts.jetstack.io condition: cert-manager.enabled - - name: mariadb version: 11.0.10 repository: https://charts.bitnami.com/bitnami diff --git a/helm/oncall/README.md b/helm/oncall/README.md index 29a3da6b..172758fe 100644 --- a/helm/oncall/README.md +++ b/helm/oncall/README.md @@ -3,12 +3,18 @@ This Grafana OnCall Chart is the best way to operate Grafana OnCall on Kubernetes. It will deploy Grafana OnCall engine and celery workers, along with RabbitMQ cluster, Redis Cluster, and MySQL 5.7 database. It will also deploy cert manager and nginx ingress controller, as Grafana OnCall backend might need to be externally available -to receive alerts from other monitoring systems. Grafana OnCall engine acts as a backend and can be connected to the Grafana frontend plugin named Grafana OnCall. +to receive alerts from other monitoring systems. Grafana OnCall engine acts as a backend and can be connected to the +Grafana frontend plugin named Grafana OnCall. Architecture diagram can be found [here](https://raw.githubusercontent.com/grafana/oncall/dev/docs/img/architecture_diagram.png) ## Production usage -**Default helm chart configuration is not intended for production.** The helm chart includes all the services into a single release, which is not recommended for production usage. It is recommended to run stateful services such as MySQL and RabbitMQ separately from this release or use managed PaaS solutions. It will significantly reduce the overhead of managing them. Here are the instructions on how to set up your own [ingress](#set-up-external-access), [MySQL](#connect-external-mysql), [RabbitMQ](#connect-external-rabbitmq), [Redis](#connect-external-redis) +**Default helm chart configuration is not intended for production.** +The helm chart includes all the services into a single release, which is not recommended for production usage. +It is recommended to run stateful services such as MySQL and RabbitMQ separately from this release or use managed +PaaS solutions. It will significantly reduce the overhead of managing them. +Here are the instructions on how to set up your own [ingress](#set-up-external-access), [MySQL](#connect-external-mysql), +[RabbitMQ](#connect-external-rabbitmq), [Redis](#connect-external-redis) ### Cluster requirements @@ -39,6 +45,8 @@ helm install \ Follow the `helm install` output to finish setting up Grafana OnCall backend and Grafana OnCall frontend plugin e.g. + + ```bash 👋 Your Grafana OnCall instance has been successfully deployed @@ -66,6 +74,8 @@ Follow the `helm install` output to finish setting up Grafana OnCall backend and 🎉🎉🎉 Done! 🎉🎉🎉 ``` + + ## Configuration You can edit values.yml to make changes to the helm chart configuration and re-deploy the release with the following command: @@ -112,9 +122,12 @@ oncall: Grafana OnCall can be connected to the external monitoring systems or grafana deployed to the other cluster. Nginx Ingress Controller and Cert Manager charts are included in the helm chart with the default configuration. -If you set the DNS A Record pointing to the external IP address of the installation with the Hostname matching base_url parameter, https will be automatically set up. If grafana is enabled in the chart values, it will also be available on `https:///grafana/`. See the details in `helm install` output. +If you set the DNS A Record pointing to the external IP address of the installation with the Hostname matching +base_url parameter, https will be automatically set up. If grafana is enabled in the chart values, it will also be +available on `https:///grafana/`. See the details in `helm install` output. -To use a different ingress controller or tls certificate management system, set the following values to false and edit ingress settings +To use a different ingress controller or tls certificate management system, set the following values to +false and edit ingress settings ```yaml ingress-nginx: @@ -252,7 +265,9 @@ helm upgrade \ grafana/oncall ``` -After re-deploying, please also update the Grafana OnCall plugin on the plugin version page. See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin-management/#update-a-plugin) for more info on updating Grafana plugins. +After re-deploying, please also update the Grafana OnCall plugin on the plugin version page. +See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin-management/#update-a-plugin) for +more info on updating Grafana plugins. ## Uninstall @@ -280,4 +295,6 @@ kubectl delete secrets certificate-tls release-oncall-cert-manager-webhook-ca re ### Issues during initial configuration -In the event that you run into issues during initial configuration, it is possible that mismatching versions between your OnCall backend and UI is the culprit. Ensure that the versions match, and if not consider updating your `helm` deployment. +In the event that you run into issues during initial configuration, it is possible that mismatching versions between +your OnCall backend and UI is the culprit. Ensure that the versions match, and if not, +consider updating your `helm` deployment. diff --git a/helm/oncall/templates/engine/job-migrate.yaml b/helm/oncall/templates/engine/job-migrate.yaml index 47667afc..2bb478fa 100644 --- a/helm/oncall/templates/engine/job-migrate.yaml +++ b/helm/oncall/templates/engine/job-migrate.yaml @@ -7,7 +7,9 @@ metadata: {{- include "oncall.engine.labels" . | nindent 4 }} spec: backoffLimit: 15 - ttlSecondsAfterFinished: 20 + {{- if .Values.migrate.ttlSecondsAfterFinished }} + ttlSecondsAfterFinished: {{ .Values.migrate.ttlSecondsAfterFinished }} + {{- end }} template: metadata: name: {{ printf "%s-migrate-%s" (include "oncall.engine.fullname" .) (now | date "2006-01-02-15-04-05") }} diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index a80dcd92..a21ce82c 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -8,7 +8,7 @@ image: # Grafana OnCall docker image repository repository: grafana/oncall tag: - pullPolicy: IfNotPresent + pullPolicy: Always # Whether to create additional service for external connections # ClusterIP service is always created @@ -140,6 +140,8 @@ oncall: # Whether to run django database migrations automatically migrate: enabled: true + # TTL can be unset by setting ttlSecondsAfterFinished: "" + ttlSecondsAfterFinished: 20 # Additional env variables to add to deployments env: {} @@ -147,7 +149,7 @@ env: {} # Enable ingress object for external access to the resources ingress: enabled: true -# className: "" + # className: "" annotations: kubernetes.io/ingress.class: "nginx" cert-manager.io/issuer: "letsencrypt-prod" @@ -155,8 +157,8 @@ ingress: - hosts: - "{{ .Values.base_url }}" secretName: certificate-tls - # Extra paths to prepend to the host configuration. If using something - # like an ALB ingress controller, you may want to configure SSL redirects + # Extra paths to prepend to the host configuration. If using something + # like an ALB ingress controller, you may want to configure SSL redirects extraPaths: [] # - path: /* # backend: @@ -206,16 +208,16 @@ mariadb: database: oncall primary: extraEnvVars: - - name: MARIADB_COLLATE - value: utf8mb4_unicode_ci - - name: MARIADB_CHARACTER_SET - value: utf8mb4 + - name: MARIADB_COLLATE + value: utf8mb4_unicode_ci + - name: MARIADB_CHARACTER_SET + value: utf8mb4 secondary: extraEnvVars: - - name: MARIADB_COLLATE - value: utf8mb4_unicode_ci - - name: MARIADB_CHARACTER_SET - value: utf8mb4 + - name: MARIADB_COLLATE + value: utf8mb4_unicode_ci + - name: MARIADB_CHARACTER_SET + value: utf8mb4 # Make sure to create the database with the following parameters: # CREATE DATABASE oncall CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/tools/pagerduty-migrator/README.md b/tools/pagerduty-migrator/README.md index c3e2f4e9..b87a8127 100644 --- a/tools/pagerduty-migrator/README.md +++ b/tools/pagerduty-migrator/README.md @@ -3,26 +3,28 @@ This tool helps to migrate PagerDuty configuration to Grafana OnCall. Resources that can be migrated using this tool: -* User notification rules -* Escalation policies -* On-call schedules -* Integrations (services) +- User notification rules +- Escalation policies +- On-call schedules +- Integrations (services) ## Limitations -* Not all integration types are supported (e.g. inbound email is not supported) -* Migrated on-call schedules in Grafana OnCall will use ICalendar files from PagerDuty -* Delays between migrated notification/escalation rules could be slightly different from original. E.g. if you have a 4-minute delay between rules in PagerDuty, the resulting delay in Grafana OnCall will be 5 minutes +- Not all integration types are supported (e.g. inbound email is not supported) +- Migrated on-call schedules in Grafana OnCall will use ICalendar files from PagerDuty +- Delays between migrated notification/escalation rules could be slightly different from original. + E.g. if you have a 4-minute delay between rules in PagerDuty, the resulting delay in Grafana OnCall will be 5 minutes ## Prerequisites -1. Make sure you have `docker` installed +1. Make sure you have `docker` installed 2. Build the docker image: `docker build -t pd-oncall-migrator .` -3. Obtain a PagerDuty API user token: https://support.pagerduty.com/docs/api-access-keys#generate-a-user-token-rest-api-key +3. Obtain a PagerDuty API user token: 4. Obtain a Grafana OnCall API token and API URL on the "Settings" page of your Grafana OnCall instance ## Migration plan + Before starting the migration process, it's useful to see a migration plan by running the tool in `plan` mode: ```shell @@ -34,11 +36,13 @@ docker run --rm \ pd-oncall-migrator ``` -Please read the generated report carefully since depending on the content of the report, some PagerDuty resources could not be migrated and some existing Grafana OnCall resources could be deleted. - -Note that users are matched by email, so if there are users in the report with "no Grafana OnCall user found with this email" error, it's possible to fix it by adding these users to your Grafana organization. -If there is a large number of unmatched users, please also [see the script](scripts/README.md) that can automatically create missing Grafana users via Grafana HTTP API. +Please read the generated report carefully since depending on the content of the report, some PagerDuty resources +could not be migrated and some existing Grafana OnCall resources could be deleted. +Note that users are matched by email, so if there are users in the report with "no Grafana OnCall user found with +this email" error, it's possible to fix it by adding these users to your Grafana organization. +If there is a large number of unmatched users, please also [see the script](scripts/README.md) that can automatically +create missing Grafana users via Grafana HTTP API. ### Example migration plan @@ -46,7 +50,7 @@ If there is a large number of unmatched users, please also [see the script](scri User notification rules report: ✅ John Doe (john.doe@example.com) (existing notification rules will be deleted) ❌ Ben Thompson (ben@example.com) — no Grafana OnCall user found with this email - + Schedule report: ✅ Support (existing schedule with name 'Support' will be deleted) ✅ Support-shadow @@ -58,15 +62,18 @@ Escalation policy report: ❌ DevOps Escalation Policy — policy references unmatched users and schedules with unmatched users ❌ Ben Thompson (ben@example.com) — no Grafana OnCall user found with this email ❌ DevOps — schedule references unmatched users - + Integration report: ✅ Support - Prometheus (existing integration with name 'Support - Prometheus' will be deleted) - ❌ DevOps - Prometheus — escalation policy 'DevOps Escalation Policy' references unmatched users or schedules with unmatched users + ❌ DevOps - Prometheus — escalation policy 'DevOps Escalation Policy' references unmatched users or schedules + with unmatched users ❌ DevOps - Email — cannot find appropriate Grafana OnCall integration type ``` ## Migration + Once you are happy with the migration report, start the migration by setting the `MODE` environment variable to `migrate`: + ```shell docker run --rm \ -e PAGERDUTY_API_TOKEN="" \ @@ -77,11 +84,13 @@ docker run --rm \ pd-oncall-migrator ``` -It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable. Options are: `email`, `sms`, `phone_call`, `slack`, `telegram` (default is `email`). +It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by +changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable. +Options are: `email`, `sms`, `phone_call`, `slack`, `telegram` (default is `email`). ### After migration -* Connect integrations (press the "How to connect" button on the integration page) -* Make sure users connect their phone numbers, Slack accounts, etc. in their user settings -* At some point you would probably want to recreate schedules using Google Calendar or Terraform to be able to modify migrated on-call schedules in Grafana OnCall - +- Connect integrations (press the "How to connect" button on the integration page) +- Make sure users connect their phone numbers, Slack accounts, etc. in their user settings +- At some point you would probably want to recreate schedules using Google Calendar or Terraform to be able to modify + migrated on-call schedules in Grafana OnCall diff --git a/tools/pagerduty-migrator/scripts/README.md b/tools/pagerduty-migrator/scripts/README.md index ddd50f87..7e376d05 100644 --- a/tools/pagerduty-migrator/scripts/README.md +++ b/tools/pagerduty-migrator/scripts/README.md @@ -1,8 +1,10 @@ # PagerDuty migrator scripts -When running the migrator in `plan` mode, it can potentially show that some users cannot be matched (meaning that there are no users in Grafana with the same email as in PagerDuty). +When running the migrator in `plan` mode, it can potentially show that some users cannot be matched +(meaning that there are no users in Grafana with the same email as in PagerDuty). -If there is a large number of unmatched users, it can be easier to use the following script that automatically creates missing Grafana users: +If there is a large number of unmatched users, it can be easier to use the following script that +automatically creates missing Grafana users: ```bash docker run --rm \