├── .github
├── dependabot.yml
├── pull_request_template.md
└── workflows
│ ├── 01_test_and_lint.yaml
│ ├── 02_publish_docs.yaml
│ └── 03_publish_to_maven.yaml
├── .gitignore
├── CHANGELOG.md
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── build.gradle
├── config
└── checkstyle
│ └── checkstyle.xml
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── scripts
├── build_docs.sh
├── is_release.sh
└── serve_docs.sh
├── settings.gradle
├── src
├── main
│ ├── java
│ │ └── com
│ │ │ └── styra
│ │ │ └── opa
│ │ │ └── springboot
│ │ │ ├── ConstantContextDataProvider.java
│ │ │ ├── ContextDataProvider.java
│ │ │ ├── OPAAuthorizationManager.java
│ │ │ ├── OPAPathSelector.java
│ │ │ ├── OPAResponse.java
│ │ │ ├── OPAResponseContext.java
│ │ │ ├── authorization
│ │ │ ├── OPAAccessDeniedException.java
│ │ │ ├── OPAAuthorizationDecision.java
│ │ │ └── OPAAuthorizationEventPublisher.java
│ │ │ ├── autoconfigure
│ │ │ ├── OPAAutoConfiguration.java
│ │ │ └── OPAProperties.java
│ │ │ └── input
│ │ │ ├── InputConstants.java
│ │ │ ├── OPAInputActionCustomizer.java
│ │ │ ├── OPAInputContextCustomizer.java
│ │ │ ├── OPAInputResourceCustomizer.java
│ │ │ ├── OPAInputSubjectCustomizer.java
│ │ │ └── OPAInputValidator.java
│ └── resources
│ │ └── META-INF
│ │ └── spring
│ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── test
│ ├── java
│ └── com
│ │ └── styra
│ │ └── opa
│ │ └── springboot
│ │ ├── BaseIntegrationTest.java
│ │ ├── OPAAuthorizationManagerTest.java
│ │ ├── OPAPathSelectorTest.java
│ │ ├── authorization
│ │ ├── BaseAuthorizationEventListenerTest.java
│ │ ├── DefaultCustomAuthorizationEventListenerTest.java
│ │ ├── ModifiedCustomAuthorizationEventListenerTest.java
│ │ └── OPAAccessDeniedExceptionTest.java
│ │ ├── autoconfigure
│ │ ├── OPAAutoConfigurationTest.java
│ │ └── properties
│ │ │ ├── DefaultOPAPropertiesTest.java
│ │ │ ├── ModifiedOPAPropertiesTest.java
│ │ │ └── ModifiedSystemEnvOPAPropertiesTest.java
│ │ └── input
│ │ ├── BaseOpaInputCustomizerIntegrationTest.java
│ │ ├── OPAInputActionCustomizerTest.java
│ │ ├── OPAInputContextCustomizerTest.java
│ │ ├── OPAInputResourceCustomizerTest.java
│ │ ├── OPAInputSubjectCustomizerTest.java
│ │ └── OPAInputValidatorTest.java
│ └── resources
│ ├── entrypoint.sh
│ ├── nginx.conf
│ └── opa.Dockerfile
└── testdata
└── simple
├── auth.rego
├── policy.rego
└── system.rego
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: /
5 | schedule:
6 | interval: daily
7 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### :nut_and_bolt: What code changed, and why?
2 |
3 | ### :+1: Definition of done
4 |
5 | ### :athletic_shoe: How to test
6 |
7 | ### :chains: Related Resources
8 |
9 |
--------------------------------------------------------------------------------
/.github/workflows/01_test_and_lint.yaml:
--------------------------------------------------------------------------------
1 | name: "01 - Run unit tests and lint Java code"
2 | on:
3 | workflow_dispatch:
4 | pull_request:
5 |
6 | jobs:
7 | test_and_lint:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - name: Set up Java
12 | uses: actions/setup-java@v4
13 | with:
14 | java-version: "17"
15 | distribution: "corretto"
16 | cache: "gradle"
17 | - uses: gradle/actions/setup-gradle@v4
18 | - run: ./gradlew test lint checkstyleMain checkstyleTest jar
19 | - run: ls -al build/libs
20 | - name: Publish Test Report
21 | uses: mikepenz/action-junit-report@v5
22 | if: success() || failure() # always run even if the previous step fails
23 | with:
24 | report_paths: "**/build/test-results/test/TEST-*.xml"
25 | - name: Publish Checkstyle report
26 | uses: Juuxel/publish-checkstyle-report@v2
27 | if: ${{ failure() || success() }}
28 | with:
29 | # required: The glob paths to report XML files as a multiline string
30 | # The format below works for the Gradle Checkstyle plugin with default configurations
31 | reports: |
32 | build/reports/checkstyle/*.xml
33 |
--------------------------------------------------------------------------------
/.github/workflows/02_publish_docs.yaml:
--------------------------------------------------------------------------------
1 | name: "02 - Publish Docs"
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - CHANGELOG.md
9 |
10 | jobs:
11 | docs:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Tune GitHub-hosted runner network
15 | uses: smorimoto/tune-github-hosted-runner-network@v1
16 | - uses: actions/checkout@v4
17 | - run: ./scripts/is_release.sh >> $GITHUB_OUTPUT
18 | id: build
19 | - name: Set up Java
20 | uses: actions/setup-java@v4
21 | with:
22 | java-version: "17"
23 | distribution: "corretto"
24 | cache: "gradle"
25 | if: ${{ steps.build.outputs.release == 'true' }}
26 | - uses: gradle/actions/setup-gradle@v4
27 | if: ${{ steps.build.outputs.release == 'true' }}
28 | - run: ./scripts/build_docs.sh ./build/docsite
29 | if: ${{ steps.build.outputs.release == 'true' }}
30 | - name: Deploy to GitHub Pages
31 | uses: peaceiris/actions-gh-pages@v4
32 | with:
33 | github_token: ${{ secrets.GITHUB_TOKEN }}
34 | publish_dir: ./build/docsite
35 | if: ${{ steps.build.outputs.release == 'true' }}
36 |
--------------------------------------------------------------------------------
/.github/workflows/03_publish_to_maven.yaml:
--------------------------------------------------------------------------------
1 | name: "03 - Publish to Maven Central"
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - CHANGELOG.md
9 |
10 | jobs:
11 | publish:
12 | name: Publish Java SDK
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Tune GitHub-hosted runner network
16 | uses: smorimoto/tune-github-hosted-runner-network@v1
17 | - uses: actions/checkout@v4
18 | - run: ./scripts/is_release.sh >> $GITHUB_OUTPUT
19 | id: build
20 | - name: Set up Java
21 | uses: actions/setup-java@v4
22 | with:
23 | java-version: "17"
24 | distribution: "corretto"
25 | cache: "gradle"
26 | if: ${{ steps.build.outputs.release == 'true' }}
27 | - uses: gradle/actions/setup-gradle@v4
28 | if: ${{ steps.build.outputs.release == 'true' }}
29 | - name: Publish to Sonatype Central
30 | run: |-
31 | pwd
32 | ./gradlew build sonatypeCentralUpload --no-daemon
33 | env:
34 | SONATYPE_USERNAME: ${{ secrets.sonatype_username }}
35 | SONATYPE_PASSWORD: ${{ secrets.sonatype_password }}
36 | SONATYPE_SIGNING_KEY: ${{ secrets.java_gpg_secret_key }}
37 | SIGNING_KEY_PASSPHRASE: ${{ secrets.java_gpg_passphrase }}
38 | if: ${{ steps.build.outputs.release == 'true' }}
39 | - uses: ravsamhq/notify-slack-action@v2
40 | if: ${{ steps.build.outputs.release == 'true' && always() && env.SLACK_WEBHOOK_URL != '' }}
41 | with:
42 | status: ${{ job.status }}
43 | token: ${{ secrets.github_access_token }}
44 | notify_when: "failure"
45 | notification_title: "Failed to Publish Maven Central Release"
46 | message_format: "{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}>"
47 | footer: "Linked Repo <{repo_url}|{repo}> | <{run_url}|View Run>"
48 | env:
49 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled class file
2 | *.class
3 |
4 | # Log file
5 | *.log
6 |
7 | # BlueJ files
8 | *.ctxt
9 |
10 | # Mobile Tools for Java (J2ME)
11 | .mtj.tmp/
12 |
13 | # Package Files #
14 | *.jar
15 | *.war
16 | *.nar
17 | *.ear
18 | *.zip
19 | *.tar.gz
20 | *.rar
21 |
22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
23 | hs_err_pid*
24 | replay_pid*
25 |
26 |
27 | # https://github.com/github/gitignore/blob/main/Gradle.gitignore
28 | .gradle
29 | **/build/
30 | !src/**/build/
31 |
32 | # Ignore Gradle GUI config
33 | gradle-app.setting
34 |
35 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
36 | !gradle-wrapper.jar
37 |
38 | # Avoid ignore Gradle wrappper properties
39 | !gradle-wrapper.properties
40 |
41 | # Cache of project
42 | .gradletasknamecache
43 |
44 | # Eclipse Gradle plugin generated files
45 | # Eclipse Core
46 | .project
47 | # JDT-specific (Eclipse Java Development Tools)
48 | .classpath
49 |
50 | # IntelliJ IDEA
51 | .idea/**
52 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # OPA Spring Boot SDK Changelog
2 |
3 | ## v1.0.1 (unreleased)
4 |
5 | ## v1.0.0
6 |
7 | * Add `OPAPathSelector` to customize path selection
8 | * Add `OPAInputSubjectCustomizer`, `OPAInputResourceCustomizer`, `OPAInputActionCustomizer` and `OPAInputContextCustomizer` beans to override default request input
9 | * Add `OPAAuthorizationEventPublisher` to publish deny or granted events
10 |
11 | ## v0.1.1
12 |
13 | * Autowire `OPAProperties` in `OPAAutoConfiguration`
14 |
15 | ## v0.1.0
16 |
17 | * Add `OPAAutoConfiguration` to auto-configure `OPAClient` and `OPAAuthorizationManager` beans. When another
18 | `OPAClient` and `OPAAuthorizationManager` is defined in Spring context, auto-configured beans will not be created.
19 | * Add `OPAProperties` to organize properties, provide default values, and externalize them (modify them through
20 | properties files, yaml files, environment variables, system properties, etc.).
21 |
22 | ## v0.0.8
23 |
24 | * Change `build.gradle` to omit the `plain` classifier from the jar file it builds. This should make the default
25 | snippet show on https://central.sonatype.com/artifact/com.styra.opa/springboot _work as is_. Before, you would
26 | have to add `plain`.
27 |
28 | ## v0.0.7
29 |
30 | * Bump `opa-java` version to 1.8.0.
31 |
32 | ## v0.0.6
33 |
34 | * Fixed a null pointer exception while constructing the input to OPA with some Authentication implementations.
35 |
36 | ## v0.0.5
37 |
38 | * Add `OPAAuthorizationManager` constructor that accepts a path and a `ContextDataProvider`, but not an `OPAClient`.
39 | * `opa-java` is now marked as an `api` dependency in `build.gradle`, so it will not be transitively exposed to users.
40 | * Bump `opa-java` version to 1.5.0.
41 |
42 | ## v0.0.4
43 |
44 | * Explicitly mark the `ContextDataProvider` interface as public.
45 | * Remove several unused dependencies, update remaining dependencies to latest stable versions.
46 |
47 | ## v0.0.3
48 |
49 | * Add `OPAAuthorizationManager` constructor that accepts a path but not an `OPAClient`.
50 |
51 | ## v0.0.2
52 |
53 | * Rather than hard-coding `en`, the preferred key to search for decision reasons for can now be changed with `OPAAuthorizationManager.setReasonKey()`. The default remains `en`.
54 | * Update `build.gradle` to accurately reflect Apache 2 license.
55 |
56 | ## v0.0.1
57 |
58 | * Initial release of the OPA Spring Boot SDK
59 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # OPA Spring Boot SDK Development
2 |
3 | ## Changelog Conventions
4 |
5 | * Each version should have an h2 heading (`##`) consisting of its semver version, using the same format as for tags (see *Release Process* below).
6 | * If a version has not been released yet, `(unreleased)` should be added to the end of the heading.
7 | * The newest version should be the first subheading, and the oldest should be the least.
8 |
9 | ## Release Process
10 |
11 | > [!IMPORTANT]
12 | > GitHub Actions workflows used for release processes will only run when `CHANGELOG.md` has been modified, and will not run if the first subheading contains `(unreleased)`.
13 |
14 | 1. Create branch `release-$VERSION`.
15 | 2. Update the version number in `build.gradle` if needed.
16 | 3. Update `CHANGELOG.md` to reflect changes made since the last version.
17 | 4. Create a PR for the release. Merge it once tests pass.
18 | 5. GitHub Actions should automatically update the docs site to reflect the release, and publish a staging repository to Maven Central.
19 | 6. Use the OSSRH portal to publish the release to Maven Central, see [this doc for instructions](https://styrainc.github.io/opa-java/maintenance/releases/).
20 | 7. Tag the release. Tags should follow semver conventions and be formatted as `v$MAJOR.$MINOR.$PATCH`.
21 | 8. Create a GitHub release, copy-paste the relevant section of the changelog into it.
22 | 9. Delete the release branch.
23 | 10. Increment the version number in `build.gradle`, and add a new section to `CHANGELOG.md` for that version with the header `## vX.Y.Z (unreleased)`
24 |
25 | ## Toolchain Setup for macOS
26 |
27 | If you do not already have a working Java 17 toolchain on macOS, you can use these steps to set one up. You must also have a working Docker installation to run the tests.
28 |
29 | 1. Install [Homebrew](https://brew.sh/), if you have not already.
30 | 2. Install the JDK with `brew install openjdk@17`.
31 | 3. Install `jenv` with `brew install jenv`.
32 | 4. You need to make `jenv` aware of the your Homebrew Java installations. This shell command will remove any existing Java installs from `jenv`, and add all OpenJDK installs from Homebrew: `jenv versions | tr -d '*' | awk '$1!="system"{print($1)}' | while read -r vers ; do jenv remove "$vers" ; done ; for p in /opt/homebrew/Cellar/openjdk* ; do for q in "$p"/* ; do jenv add "$q" ; done ; done`
33 | 5. Use `jenv` to select version 17 with `jenv global 17`.
34 |
35 | Further reading:
36 |
37 | * [Step-by-Step Guide: Installing and Switching Java Versions on Mac OSX](https://medium.com/@haroldfinch01/step-by-step-guide-installing-and-switching-java-versions-on-mac-osx-f3896b9872f4)
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OPA Spring Boot SDK
2 |
3 | > [!IMPORTANT]
4 | > The documentation for this SDK lives at [https://docs.styra.com/sdk](https://docs.styra.com/sdk), with reference documentation available at [https://styrainc.github.io/opa-springboot/javadoc](https://styrainc.github.io/opa-springboot/javadoc)
5 |
6 | You can use the Styra OPA Spring Boot SDK to connect [Open Policy Agent](https://www.openpolicyagent.org/) and [Enterprise OPA](https://www.styra.com/enterprise-opa/) deployments to your [Spring Boot](https://spring.io/projects/spring-boot) applications using the included [AuthorizationManager](https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html#_the_authorizationmanager) implementation.
7 |
8 | > [!IMPORTANT]
9 | > Would you prefer a plain Java API instead of Spring Boot? Check out the [OPA Java SDK](https://github.com/StyraInc/opa-java).
10 |
11 | ## SDK Installation
12 |
13 | This package is published on Maven Central as [`com.styra.opa:springboot`](https://central.sonatype.com/artifact/com.styra.opa/springboot). The Maven Central page includes up-to-date instructions to add it as a dependency to your Java project, tailored to a variety of build systems including Maven and Gradle.
14 |
15 | If you wish to build from source and publish the SDK artifact to your local Maven repository (on your filesystem) then use the following command (after cloning the git repo locally):
16 |
17 | On Linux/MacOS:
18 |
19 | ```shell
20 | ./gradlew publishToMavenLocal -Pskip.signing
21 | ```
22 |
23 | On Windows:
24 |
25 | ```shell
26 | gradlew.bat publishToMavenLocal -"Pskip.signing"
27 | ```
28 |
29 | ## SDK Example Usage
30 | ### OPAAuthorizationManager
31 | Using `OPAAuthorizationManager`, HTTP requests could be authorized:
32 |
33 | ```java
34 | import com.styra.opa.springboot.OPAAuthorizationManager;
35 |
36 | @Configuration
37 | @EnableWebSecurity
38 | public class SecurityConfig {
39 |
40 | @Autowired
41 | OPAAuthorizationManager opaAuthorizationManager;
42 |
43 | @Bean
44 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
45 | http.authorizeHttpRequests(authorize -> authorize.anyRequest().access(opaAuthorizationManager));
46 | // Other security configs
47 | return http.build();
48 | }
49 | }
50 | ```
51 | Auto-configuration will be done using `OPAAutoConfiguration`. If any customization be needed, custom `OPAClient`
52 | or `OPAAuthorizationManager` beans could be defined by clients.
53 |
54 | ### OPAClient
55 | A custom `OPAClient` bean could be defined to send custom headers to the OPA server, or using custom
56 | `com.styra.opa.openapi.utils.HTTPClient`, such as:
57 |
58 | ```java
59 | import com.styra.opa.OPAClient;
60 |
61 | @Configuration
62 | public class OPAConfig {
63 |
64 | @Bean
65 | public OPAClient opaClient(OPAProperties opaProperties) {
66 | var headers = Map.ofEntries(entry("Authorization", "Bearer secret"));
67 | return new OPAClient(opaProperties.getUrl(), headers);
68 | }
69 | }
70 | ```
71 |
72 | ### OPAProperties
73 | Configuration properties are defined in `OPAProperties` and can be set
74 | [externally](https://docs.spring.io/spring-boot/reference/features/external-config.html), e.g. via
75 | `application.properties`, `application.yaml`, system properties, or environment variables.
76 |
77 | Example `application.yaml` to modify properties:
78 | ```yaml
79 | opa:
80 | url: http://localhost:8182 # OPA server URL. Default is "http://localhost:8181".
81 | path: foo/bar # Policy path in OPA. Default is null.
82 | request:
83 | resource:
84 | type: stomp_endpoint # Type of the request's resource. Default is "endpoint".
85 | context:
86 | type: websocket # Type of the request's context. Default is "http".
87 | subject:
88 | type: oauth2_resource_owner # Type of the request's subject. Default is "java_authentication".
89 | response:
90 | context:
91 | reason-key: de # Key to search for decision reasons in the response. Default is "en".
92 | authorization-event:
93 | denied:
94 | enabled: false # Whether to publish an AuthorizationDeniedEvent when a request is denied. Default is true.
95 | granted:
96 | enabled: true # Whether to publish an AuthorizationGrantedEvent when a request is granted. Default is false.
97 | ```
98 |
99 | ### OPAPathSelector
100 | By default, OPAAuthorizationManager does not use any path when calling OPA (evaluating policies). Clients could define
101 | an `OPAPathSelector` bean, which could select paths based on the `Authentication`, `RequestAuthorizationContext`, or
102 | opaInput `Map`.
103 |
104 | Example `OPAPathSelector` bean:
105 | ```java
106 | @Configuration
107 | public class OPAConfig {
108 | @Bean
109 | public OPAPathSelector opaPathSelector() {
110 | return (authentication, requestAuthorizationContext, opaInput) -> {
111 | String httpRequestPath = requestAuthorizationContext.getRequest().getServletPath();
112 | if (httpRequestPath.startsWith("/foo")) {
113 | return "foo/main";
114 | } else if (httpRequestPath.startsWith("/bar")) {
115 | return "bar/main";
116 | } else {
117 | return "default/main";
118 | }
119 | };
120 | }
121 | }
122 | ```
123 |
124 | ### OPAInput*Customizers
125 | OPA `input` is a `Map` which will be sent to the
126 | [Get a Document (with Input) endpoint](https://www.openpolicyagent.org/docs/latest/rest-api/#get-a-document-with-input)
127 | as the `input` field in the request body and is accessible in OPA policies as the
128 | [input variable](https://www.openpolicyagent.org/docs/latest/philosophy/#the-opa-document-model). To enable clients to
129 | customize different parts of `input` (`subject`, `resource`, `action`, and `context`), `OPAInput*Customizer` beans
130 | could be defined. After applying `input` customization, `input` will be validated to ensure it at least contains these
131 | keys with not-null values:
132 | - `resource.[type, id]`
133 | - `action.name`
134 | - `subject.[type, id]`
135 | - `context.type`, if `context` exists
136 |
137 | #### 1. OPAInputSubjectCustomizer
138 | Clients could define an `OPAInputSubjectCustomizer` bean to customize the `subject` part of the `input`. `subject` map
139 | must at least contain `type` and `id` keys with not-null values, though their values could be modified.
140 |
141 | Example `OPAInputSubjectCustomizer` bean:
142 | ```java
143 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
144 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_AUTHORITIES;
145 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
146 |
147 | @Configuration
148 | public class OPAConfig {
149 | @Bean
150 | public OPAInputSubjectCustomizer opaInputSubjectCustomizer() {
151 | return (authentication, requestAuthorizationContext, subject) -> {
152 | var customSubject = new HashMap<>(subject);
153 | customSubject.remove(SUBJECT_AUTHORITIES); // Remove an existing attribute.
154 | customSubject.put(SUBJECT_TYPE, "oauth2_resource_owner"); // Change an existing attribute.
155 | customSubject.put("subject_key", "subject_value"); // Add a new attribute.
156 | return customSubject;
157 | };
158 | }
159 | }
160 | ```
161 |
162 | #### 2. OPAInputResourceCustomizer
163 | Clients could define an `OPAInputResourceCustomizer` bean to customize the `resource` part of the `input`. `resource`
164 | map must at least contain `type` and `id` keys with not-null values, though their values could be modified.
165 |
166 | Example `OPAInputResourceCustomizer` bean:
167 | ```java
168 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
169 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
170 |
171 | @Configuration
172 | public class OPAConfig {
173 | @Bean
174 | public OPAInputResourceCustomizer opaInputResourceCustomizer() {
175 | return (authentication, requestAuthorizationContext, resource) -> {
176 | var customResource = new HashMap<>(resource);
177 | customResource.put(RESOURCE_TYPE, "stomp_endpoint"); // Change an existing attribute.
178 | customResource.put("resource_key", "resource_value"); // Add a new attribute.
179 | return customResource;
180 | };
181 | }
182 | }
183 | ```
184 |
185 | #### 3. OPAInputActionCustomizer
186 | Clients could define an `OPAInputActionCustomizer` bean to customize the `action` part of the `input`. `action` map
187 | must at least contain `name` key with a not-null value, though its value could be modified.
188 |
189 | Example `OPAInputActionCustomizer` bean:
190 | ```java
191 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
192 | import static com.styra.opa.springboot.input.InputConstants.ACTION_HEADERS;
193 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
194 |
195 | @Configuration
196 | public class OPAConfig {
197 | @Bean
198 | public OPAInputActionCustomizer opaInputActionCustomizer() {
199 | return (authentication, requestAuthorizationContext, action) -> {
200 | var customAction = new HashMap<>(action);
201 | customAction.remove(ACTION_HEADERS); // Remove an existing attribute.
202 | customAction.put(ACTION_NAME, "read"); // Change an existing attribute.
203 | customAction.put("action_key", "action_value"); // Add a new attribute.
204 | return customAction;
205 | };
206 | }
207 | }
208 | ```
209 |
210 | #### 4. OPAInputContextCustomizer
211 | Clients could define an `OPAInputContextCustomizer` bean to customize the `context` part of the `input`. `context` map
212 | could be null; however if it is not-null, it must at least contain `type` key with a not-null value, though its value
213 | could be modified.
214 |
215 | Example `OPAInputContextCustomizer` bean which makes `context` null (removes it from `input` map):
216 | ```java
217 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
218 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
219 |
220 | @Configuration
221 | public class OPAConfig {
222 | @Bean
223 | public OPAInputContextCustomizer opaInputContextCustomizer() {
224 | return (authentication, requestAuthorizationContext, context) -> null;
225 | }
226 | }
227 | ```
228 |
229 | ### Authorization Events
230 | Spring-Security supports
231 | [Authorization Events](https://docs.spring.io/spring-security/reference/servlet/authorization/events.html) which could
232 | be used to publish events when a request is authorized or denied. By following the spring-security convention,
233 | `OPAAuthorizationEventPublisher` publishes `AuthorizationDeniedEvent` when a request is denied, however does not
234 | publish `AuthorizationGrantedEvent` when a request is granted (since it could be quite noisy). Clients could change
235 | this behavior via `opa.authorization-event.denied.enabled` and `opa.authorization-event.granted.enabled` properties.
236 |
237 | > [!IMPORTANT]
238 | > Besides this feature, OPA server can periodically report decision logs to remote HTTP servers, using custom
239 | plugins, or to the console output; or any combination thereof. The decision logs contain events that describe policy
240 | queries. The decision logs is the preferred logging mechanism for large-scale deployments, as it unifies decision logs
241 | regardless of the client technologies. For more information, see the
242 | [OPA Decision Logs](https://www.openpolicyagent.org/docs/latest/management-decision-logs/).
243 |
244 | Emitted `AuthorizationDeniedEvent` and `AuthorizationGrantedEvent` contain `OPAAuthorizationDecision` which has a
245 | reference to the corresponding `OPAResponse` and clients could access the response returned from the OPA server. In
246 | order to listen to these events, clients could annotate a method with `@EventListener` in a bean.
247 |
248 | Example listener to receive both denied and granted events:
249 | ```java
250 | import org.springframework.context.event.EventListener;
251 |
252 | @Component
253 | public class OPAAuthorizationEventListener {
254 |
255 | @EventListener
256 | public void onDeny(AuthorizationDeniedEvent denied) {
257 | // ...
258 | }
259 |
260 | @EventListener
261 | public void onGrant(AuthorizationGrantedEvent granted) {
262 | // ...
263 | }
264 | }
265 | ```
266 |
267 | ### Handling OPAAccessDeniedException
268 | When a request is denied, `OPAAuthorizationManager` throws an `OPAAccessDeniedException`. Clients could handle this
269 | exception by implementing `AccessDeniedHandler` or extending `AccessDeniedHandlerImpl`.
270 |
271 | Example `AccessDeniedHandler` bean to handle `OPAAccessDeniedException` and generate HTTP responses based on
272 | [RFC 9457 - Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457):
273 | ```java
274 | import com.fasterxml.jackson.databind.ObjectMapper;
275 | import com.styra.opa.springboot.authorization.OPAAccessDeniedException;
276 | import org.springframework.security.access.AccessDeniedException;
277 | import org.springframework.security.web.access.AccessDeniedHandlerImpl;
278 |
279 | @Component
280 | public class OPAAccessDeniedHandler extends AccessDeniedHandlerImpl {
281 |
282 | @Autowired
283 | private ObjectMapper objectMapper;
284 |
285 | @Override
286 | public void handle(HttpServletRequest request, HttpServletResponse response,
287 | AccessDeniedException accessDeniedException) throws IOException, ServletException {
288 | if (!(accessDeniedException instanceof OPAAccessDeniedException opaAccessDeniedException)) {
289 | super.handle(request, response, accessDeniedException);
290 | return;
291 | }
292 | Map body = new HashMap<>();
293 | body.put("status", HttpStatus.FORBIDDEN.value());
294 | body.put("title", opaAccessDeniedException.getMessage());
295 | var subject = (Map) opaAccessDeniedException.getOpaResponse().getContext().getData().get(SUBJECT);
296 | var subjectId = subject.get(SUBJECT_ID);
297 | body.put("detail", "Access denied for subject: " + subjectId);
298 | body.put("subject", subject);
299 | response.setStatus(HttpStatus.FORBIDDEN.value());
300 | response.setContentType(MediaType.APPLICATION_JSON_VALUE);
301 | response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
302 | response.getWriter().write(objectMapper.writeValueAsString(body));
303 | response.getWriter().flush();
304 | }
305 | }
306 | ```
307 |
308 | ## Policy Input/Output Schema
309 |
310 | Documentation for the required input and output schema of policies used by the OPA Spring Boot SDK can be found [here](https://docs.styra.com/sdk/springboot/reference/input-output-schema).
311 |
312 | ## Build Instructions
313 |
314 | **To build the SDK**, use `./gradlew build`, the resulting JAR will be placed in `./build/libs/api.jar`.
315 |
316 | **To build the documentation** site, including JavaDoc, run `./scripts/build_docs.sh OUTPUT_DIR`. You should replace `OUTPUT_DIR` with a directory on your local system where you would like the generated docs to be placed. You can also preview the documentation site ephemerally using `./scripts/serve_docs.sh`, which will serve the docs on `http://localhost:8000` until you use Ctrl+C to exit the script.
317 |
318 | **To run the unit tests**, you can use `./gradlew test`.
319 |
320 | **To run the linter**, you can use `./gradlew lint`.
321 |
322 | ## Community
323 |
324 | For questions, discussions and announcements related to Styra products, services and open source projects, please join
325 | the Styra community on [Slack](https://communityinviter.com/apps/styracommunity/signup)!
326 |
327 | ## Development
328 |
329 | For development docs, see [DEVELOPMENT.md](./DEVELOPMENT.md).
330 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'java'
3 | id 'java-library'
4 | id 'maven-publish'
5 | id 'signing'
6 | id 'cl.franciscosolis.sonatype-central-upload' version '1.0.3'
7 | id 'org.springframework.boot' version '3.3.5'
8 | id 'io.spring.dependency-management' version '1.1.6'
9 | id("checkstyle")
10 | }
11 |
12 | java {
13 | sourceCompatibility = '17'
14 | targetCompatibility = '17'
15 |
16 | withSourcesJar()
17 | withJavadocJar()
18 | }
19 |
20 | configurations {
21 | compileOnly {
22 | extendsFrom annotationProcessor
23 | }
24 | }
25 |
26 | tasks.named("bootJar") {
27 | archiveClassifier = 'boot'
28 | }
29 |
30 | tasks.named("jar") {
31 | archiveClassifier = ''
32 | }
33 |
34 | repositories {
35 | mavenCentral()
36 | }
37 |
38 | javadoc {
39 | options.addBooleanOption("Xdoclint:-missing", true)
40 | options.links += [
41 | "https://styrainc.github.io/opa-java/javadoc/",
42 | "https://docs.spring.io/spring-security/site/docs/current/api/",
43 | "https://docs.spring.io/spring-boot/api/java/",
44 | ]
45 | }
46 |
47 | dependencies {
48 | implementation 'org.springframework.boot:spring-boot-starter-web'
49 | implementation 'org.springframework.security:spring-security-web'
50 | implementation 'org.springframework.security:spring-security-core'
51 | implementation 'org.springframework:spring-context'
52 |
53 | testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
54 | testImplementation 'org.springframework.boot:spring-boot-starter-test'
55 | testImplementation 'org.springframework.security:spring-security-test'
56 | testImplementation 'org.springframework.security:spring-security-config'
57 | testImplementation 'org.testcontainers:testcontainers-bom:1.19.8'
58 | testImplementation 'org.testcontainers:testcontainers:1.19.8'
59 | testImplementation 'org.testcontainers:junit-jupiter:1.19.8'
60 |
61 | api group: 'com.styra', name: 'opa', version: '1.8.0'
62 |
63 | compileOnly 'org.projectlombok:lombok:1.18.34'
64 | annotationProcessor 'org.projectlombok:lombok:1.18.34'
65 | }
66 |
67 | apply plugin: 'application'
68 | mainClassName = 'com.styra.opa.springboot'
69 |
70 | tasks.named('test') {
71 | useJUnitPlatform()
72 | }
73 |
74 | // https://discuss.gradle.org/t/how-to-exclude-checkstyle-task-from-build-task/6692/5
75 | //
76 | // This prevents Checkstyle from running on ./gradlew build, but keeps it
77 | // working for ./gradlew lint.
78 | checkstyle {
79 | sourceSets = []
80 | }
81 |
82 | tasks.withType(Checkstyle) {
83 | configFile = file("${project.rootDir}/config/checkstyle/checkstyle.xml")
84 | }
85 |
86 | task lint {
87 | dependsOn checkstyleTest
88 | dependsOn checkstyleMain
89 |
90 | // Note that Gradle linting is disabled because it reports problems that
91 | // simply cannot be fixed. For example, it insists that lombok is unused
92 | // when it is. It claims tomcat duplicates classes from jakarta, which may
93 | // be so but that's getting pulled in by Spring, so we can't control that.
94 | }
95 |
96 | test {
97 | useJUnitPlatform()
98 | exclude 'com/styra/opa/springboot/autoconfigure/properties/ModifiedSystemEnvOPAPropertiesTest.class'
99 | testLogging {
100 | // uncomment for more verbose output during development
101 | //events "passed", "skipped", "failed", "standard_out", "standard_error"
102 | }
103 | }
104 | tasks.register("testModifiedSystemEnvProperties", Test) {
105 | useJUnitPlatform()
106 | group = "verification"
107 | include 'com/styra/opa/springboot/autoconfigure/properties/ModifiedSystemEnvOPAPropertiesTest.class'
108 | doFirst {
109 | systemProperty 'opa.url', 'http://localhost:8183'
110 | environment 'OPA_PATH', 'foo/bar2'
111 | }
112 | doLast {
113 | systemProperties.remove('opa.url')
114 | environment.remove('OPA_PATH')
115 | }
116 | }
117 | test.finalizedBy(testModifiedSystemEnvProperties)
118 |
119 | gradle.projectsEvaluated {
120 | tasks.withType(JavaCompile) {
121 | options.compilerArgs << "-Xlint:unchecked"
122 | }
123 | }
124 |
125 | sonatypeCentralUpload {
126 | // This is your Sonatype generated username
127 | username = System.getenv("SONATYPE_USERNAME")
128 | // This is your sonatype generated password
129 | password = System.getenv("SONATYPE_PASSWORD")
130 |
131 | // This is a list of files to upload. Ideally you would point to your jar file, source and javadoc jar (required by central)
132 | archives = files(
133 | "$buildDir/libs/${artifactId}-${version}.jar",
134 | "$buildDir/libs/${artifactId}-${version}-sources.jar",
135 | "$buildDir/libs/${artifactId}-${version}-javadoc.jar"
136 | )
137 |
138 | // This is the pom file to upload. This is required by central
139 | pom = file("$buildDir/pom.xml")
140 |
141 | // This is your PGP private key. This is required to sign your files
142 | signingKey = System.getenv("SONATYPE_SIGNING_KEY")
143 | // This is your PGP private key passphrase to decrypt your private key
144 | signingKeyPassphrase = System.getenv("SIGNING_KEY_PASSPHRASE")
145 | }
146 |
147 | publishing {
148 | publications {
149 | maven(MavenPublication) {
150 | groupId = "${groupId}"
151 | artifactId = "${artifactId}"
152 | version = "${version}"
153 |
154 | from components.java
155 |
156 | pom {
157 | name = 'OPA Spring Boot SDK'
158 | description = 'The Styra-supported driver to connect Spring Boot applications to Open Policy Agent (OPA) and Enterprise OPA deployments'
159 | url = 'https://github.com/styrainc/opa-springboot'
160 | scm {
161 | url = 'github.com/styrainc/opa-springboot'
162 | connection = 'scm:git:ssh://git@github.com/styrainc/opa-springboot.git'
163 | }
164 | licenses {
165 | license {
166 | name = 'The Apache License, Version 2.0'
167 | url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
168 | }
169 | }
170 | developers {
171 | developer {
172 | name = 'Styra'
173 | organization = 'Styra'
174 | email = 'devrel@styra.com'
175 | }
176 | }
177 | organization {
178 | name = 'Styra'
179 | url = 'www.styra.com'
180 | }
181 | }
182 | }
183 | }
184 | }
185 |
186 | if (!project.hasProperty('skip.signing')) {
187 | signing {
188 | def signingKey = findProperty("signingKey")
189 | def signingPassphrase = findProperty("signingPassphrase")
190 | useInMemoryPgpKeys(signingKey, signingPassphrase)
191 | sign publishing.publications.maven
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/config/checkstyle/checkstyle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | groupId = 'com.styra.opa'
2 | artifactId = 'springboot'
3 | version = '1.0.1'
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StyraInc/opa-springboot/001429a3450b5419c93e441c4c230c5d153aedfe/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90 | ' "$PWD" ) || exit
91 |
92 | # Use the maximum available, or set MAX_FD != -1 to use that value.
93 | MAX_FD=maximum
94 |
95 | warn () {
96 | echo "$*"
97 | } >&2
98 |
99 | die () {
100 | echo
101 | echo "$*"
102 | echo
103 | exit 1
104 | } >&2
105 |
106 | # OS specific support (must be 'true' or 'false').
107 | cygwin=false
108 | msys=false
109 | darwin=false
110 | nonstop=false
111 | case "$( uname )" in #(
112 | CYGWIN* ) cygwin=true ;; #(
113 | Darwin* ) darwin=true ;; #(
114 | MSYS* | MINGW* ) msys=true ;; #(
115 | NONSTOP* ) nonstop=true ;;
116 | esac
117 |
118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119 |
120 |
121 | # Determine the Java command to use to start the JVM.
122 | if [ -n "$JAVA_HOME" ] ; then
123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124 | # IBM's JDK on AIX uses strange locations for the executables
125 | JAVACMD=$JAVA_HOME/jre/sh/java
126 | else
127 | JAVACMD=$JAVA_HOME/bin/java
128 | fi
129 | if [ ! -x "$JAVACMD" ] ; then
130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131 |
132 | Please set the JAVA_HOME variable in your environment to match the
133 | location of your Java installation."
134 | fi
135 | else
136 | JAVACMD=java
137 | if ! command -v java >/dev/null 2>&1
138 | then
139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140 |
141 | Please set the JAVA_HOME variable in your environment to match the
142 | location of your Java installation."
143 | fi
144 | fi
145 |
146 | # Increase the maximum file descriptors if we can.
147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148 | case $MAX_FD in #(
149 | max*)
150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151 | # shellcheck disable=SC2039,SC3045
152 | MAX_FD=$( ulimit -H -n ) ||
153 | warn "Could not query maximum file descriptor limit"
154 | esac
155 | case $MAX_FD in #(
156 | '' | soft) :;; #(
157 | *)
158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159 | # shellcheck disable=SC2039,SC3045
160 | ulimit -n "$MAX_FD" ||
161 | warn "Could not set maximum file descriptor limit to $MAX_FD"
162 | esac
163 | fi
164 |
165 | # Collect all arguments for the java command, stacking in reverse order:
166 | # * args from the command line
167 | # * the main class name
168 | # * -classpath
169 | # * -D...appname settings
170 | # * --module-path (only if needed)
171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172 |
173 | # For Cygwin or MSYS, switch paths to Windows format before running java
174 | if "$cygwin" || "$msys" ; then
175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177 |
178 | JAVACMD=$( cygpath --unix "$JAVACMD" )
179 |
180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
181 | for arg do
182 | if
183 | case $arg in #(
184 | -*) false ;; # don't mess with options #(
185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186 | [ -e "$t" ] ;; #(
187 | *) false ;;
188 | esac
189 | then
190 | arg=$( cygpath --path --ignore --mixed "$arg" )
191 | fi
192 | # Roll the args list around exactly as many times as the number of
193 | # args, so each arg winds up back in the position where it started, but
194 | # possibly modified.
195 | #
196 | # NB: a `for` loop captures its iteration list before it begins, so
197 | # changing the positional parameters here affects neither the number of
198 | # iterations, nor the values presented in `arg`.
199 | shift # remove old arg
200 | set -- "$@" "$arg" # push replacement arg
201 | done
202 | fi
203 |
204 |
205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207 |
208 | # Collect all arguments for the java command:
209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210 | # and any embedded shellness will be escaped.
211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212 | # treated as '${Hostname}' itself on the command line.
213 |
214 | set -- \
215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
216 | -classpath "$CLASSPATH" \
217 | org.gradle.wrapper.GradleWrapperMain \
218 | "$@"
219 |
220 | # Stop when "xargs" is not available.
221 | if ! command -v xargs >/dev/null 2>&1
222 | then
223 | die "xargs is not available"
224 | fi
225 |
226 | # Use "xargs" to parse quoted args.
227 | #
228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229 | #
230 | # In Bash we could simply go:
231 | #
232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233 | # set -- "${ARGS[@]}" "$@"
234 | #
235 | # but POSIX shell has neither arrays nor command substitution, so instead we
236 | # post-process each arg (as a line of input to sed) to backslash-escape any
237 | # character that might be a shell metacharacter, then use eval to reverse
238 | # that process (while maintaining the separation between arguments), and wrap
239 | # the whole thing up as a single "set" statement.
240 | #
241 | # This will of course break if any of these variables contains a newline or
242 | # an unmatched quote.
243 | #
244 |
245 | eval "set -- $(
246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247 | xargs -n1 |
248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249 | tr '\n' ' '
250 | )" '"$@"'
251 |
252 | exec "$JAVACMD" "$@"
253 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/scripts/build_docs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This script builds the static documentation site. You need to have Gradle and
4 | # MkDocs set up and working on your system to run it.
5 |
6 | set -x
7 | set -e
8 | set -u
9 | cd "$(dirname "$0")/.."
10 |
11 | if [ $# -ne 1 ] ; then
12 | echo "usage: $0 OUTPUT_DIR" 1>&2
13 | exit 1
14 | fi
15 |
16 | OUTPUT_DIR="$1"
17 | if [ ! -d "$OUTPUT_DIR" ] ; then
18 | mkdir -p "$OUTPUT_DIR"
19 | fi
20 | OUTPUT_DIR="$(realpath "$OUTPUT_DIR")"
21 |
22 | TEMP="$(mktemp -d)"
23 | trap "rm -rf '$TEMP'" EXIT
24 |
25 | ./gradlew build javadoc -x test -x lint
26 |
27 | mkdir "$TEMP/javadoc"
28 | cp -R ./build/docs/javadoc/* "$TEMP/javadoc"
29 |
30 | cat <<'EOF' > "$TEMP/index.html"
31 |
32 |
33 |
34 |
35 |
36 |
39 | Page Redirection
40 |
41 |
42 | redirect to OPA Spring Boot SDK javadoc.
43 |
44 |
45 | EOF
46 |
47 | cd "$TEMP"
48 |
49 | cp -R * "$OUTPUT_DIR"
50 |
51 |
--------------------------------------------------------------------------------
/scripts/is_release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This script checks weather the most recent version in CHANGELOG.md contains
4 | # the string `(unrelease)`. It is used to decide weather or not to proceed with
5 | # release related activities in GitHub Actions.
6 |
7 | cd "$(dirname "$0")/.."
8 |
9 | # match '## 0.0.9 (unreleased)'
10 | awk '$1 == "##" && $0 ~ /unreleased/ { r=1 } END { print (r==1 ? "release=false" : "release=true") }' < CHANGELOG.md
11 |
--------------------------------------------------------------------------------
/scripts/serve_docs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This script can be used to serve the MkDocs site locally. You need to have
4 | # Gradle working (to build the javadoc component), Python (to serve the docs
5 | # locally), and mkdocs (to generate the rest of the site) all set up before
6 | # you run this script.
7 |
8 | set -x
9 | set -e
10 | set -u
11 | cd "$(dirname "$0")/.."
12 |
13 | TEMP="$(mktemp -d)"
14 | trap "rm -rf '$TEMP'" EXIT
15 |
16 | ./scripts/build_docs.sh "$TEMP"
17 | cd "$TEMP"
18 |
19 | python3 -m http.server
20 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'springboot'
2 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/ConstantContextDataProvider.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import org.springframework.security.core.Authentication;
4 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
5 |
6 | import java.util.function.Supplier;
7 |
8 | /**
9 | * This helper class implements {@link ContextDataProvider} and always returns the same constant value passed in the
10 | * constructor. This is useful for tests, and also for situations where the extra data to inject does not change during
11 | * runtime.
12 | */
13 | public class ConstantContextDataProvider implements ContextDataProvider {
14 |
15 | private final Object data;
16 |
17 | public ConstantContextDataProvider(Object data) {
18 | this.data = data;
19 | }
20 |
21 | @Override
22 | public Object getContextData(Supplier authentication, RequestAuthorizationContext object) {
23 | return data;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/ContextDataProvider.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import org.springframework.security.core.Authentication;
4 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
5 |
6 | import java.util.function.Supplier;
7 |
8 | /**
9 | * This interface can be used to expose additional information to the OPA policy. Data returned by
10 | * {@link #getContextData(Supplier, RequestAuthorizationContext)} is placed in {@code input.context.data}. The
11 | * returned object must be JSON serializable.
12 | */
13 | @FunctionalInterface
14 | public interface ContextDataProvider {
15 | Object getContextData(Supplier authentication, RequestAuthorizationContext object);
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/OPAAuthorizationManager.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import com.fasterxml.jackson.core.type.TypeReference;
4 | import com.styra.opa.OPAClient;
5 | import com.styra.opa.OPAException;
6 | import com.styra.opa.springboot.authorization.OPAAccessDeniedException;
7 | import com.styra.opa.springboot.authorization.OPAAuthorizationDecision;
8 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
9 | import com.styra.opa.springboot.input.OPAInputActionCustomizer;
10 | import com.styra.opa.springboot.input.OPAInputContextCustomizer;
11 | import com.styra.opa.springboot.input.OPAInputResourceCustomizer;
12 | import com.styra.opa.springboot.input.OPAInputSubjectCustomizer;
13 | import com.styra.opa.springboot.input.OPAInputValidator;
14 | import jakarta.servlet.http.HttpServletRequest;
15 | import lombok.Getter;
16 | import org.slf4j.Logger;
17 | import org.slf4j.LoggerFactory;
18 | import org.springframework.beans.factory.annotation.Autowired;
19 | import org.springframework.security.authorization.AuthorizationDecision;
20 | import org.springframework.security.authorization.AuthorizationManager;
21 | import org.springframework.security.core.Authentication;
22 | import org.springframework.security.core.GrantedAuthority;
23 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
24 | import org.springframework.stereotype.Component;
25 |
26 | import java.util.Collection;
27 | import java.util.Enumeration;
28 | import java.util.HashMap;
29 | import java.util.Map;
30 | import java.util.Optional;
31 | import java.util.function.Supplier;
32 |
33 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
34 | import static com.styra.opa.springboot.input.InputConstants.ACTION_HEADERS;
35 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
36 | import static com.styra.opa.springboot.input.InputConstants.ACTION_PROTOCOL;
37 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
38 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_DATA;
39 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_HOST;
40 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_IP;
41 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_PORT;
42 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
43 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
44 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_ID;
45 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
46 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
47 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_AUTHORITIES;
48 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_DETAILS;
49 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_ID;
50 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
51 | import static java.util.Map.entry;
52 |
53 | /**
54 | * This class implements {@link AuthorizationManager} which wraps the
55 | * OPA Java SDK. Authorization will be done in
56 | * {@link #check(Supplier, RequestAuthorizationContext)} and {@link #verify(Supplier, RequestAuthorizationContext)} by:
57 | *
58 | * - constructing an input
59 | * (map) based on {@link Authentication} and {@link RequestAuthorizationContext}
60 | * - sending an HTTP request with the input as the request body to the OPA server
61 | * - receiving the output
62 | * as an {@link OPAResponse} and using it for authorization
63 | *
64 | * OPA input (request body) and response are compliant with the
65 | * AuthZEN spec.
66 | */
67 | @Component
68 | public class OPAAuthorizationManager implements AuthorizationManager {
69 |
70 | private static final Logger LOGGER = LoggerFactory.getLogger(OPAAuthorizationManager.class);
71 |
72 | private final String opaPath;
73 | @Getter
74 | private String reasonKey = OPAProperties.Response.Context.DEFAULT_REASON_KEY;
75 | private final ContextDataProvider contextDataProvider;
76 | private final OPAClient opaClient;
77 | @Autowired
78 | private OPAProperties opaProperties;
79 | @Autowired
80 | private OPAPathSelector opaPathSelector;
81 | @Autowired(required = false)
82 | private OPAInputSubjectCustomizer opaInputSubjectCustomizer;
83 | @Autowired(required = false)
84 | private OPAInputResourceCustomizer opaInputResourceCustomizer;
85 | @Autowired(required = false)
86 | private OPAInputActionCustomizer opaInputActionCustomizer;
87 | @Autowired(required = false)
88 | private OPAInputContextCustomizer opaInputContextCustomizer;
89 | @Autowired
90 | private OPAInputValidator opaInputValidator;
91 |
92 | public OPAAuthorizationManager() {
93 | this(null, null, null);
94 | }
95 |
96 | /**
97 | * @see OPAAuthorizationManager#OPAAuthorizationManager(OPAClient, String, ContextDataProvider)
98 | */
99 | public OPAAuthorizationManager(OPAClient opaClient) {
100 | this(opaClient, null, null);
101 | }
102 |
103 | /**
104 | * @see OPAAuthorizationManager#OPAAuthorizationManager(OPAClient, String, ContextDataProvider)
105 | */
106 | public OPAAuthorizationManager(String opaPath) {
107 | this(null, opaPath, null);
108 | }
109 |
110 | /**
111 | * @see OPAAuthorizationManager#OPAAuthorizationManager(OPAClient, String, ContextDataProvider)
112 | */
113 | public OPAAuthorizationManager(OPAClient opaClient, String opaPath) {
114 | this(opaClient, opaPath, null);
115 | }
116 |
117 | /**
118 | * @see OPAAuthorizationManager#OPAAuthorizationManager(OPAClient, String, ContextDataProvider)
119 | */
120 | public OPAAuthorizationManager(OPAClient opaClient, ContextDataProvider contextDataProvider) {
121 | this(opaClient, null, contextDataProvider);
122 | }
123 |
124 | /**
125 | * @see OPAAuthorizationManager#OPAAuthorizationManager(OPAClient, String, ContextDataProvider)
126 | */
127 | public OPAAuthorizationManager(String opaPath, ContextDataProvider contextDataProvider) {
128 | this(null, opaPath, contextDataProvider);
129 | }
130 |
131 | /**
132 | * Instantiates an instance to authorizes requests.
133 | *
134 | * @param opaClient if null, a default {@link OPAClient} will be created using {@code OPA_URL} environment variable
135 | * or default OPA url ({@value OPAProperties#DEFAULT_URL}).
136 | * @param opaPath if null, the default path defined by the OPA configuration will be used, unless an
137 | * {@link OPAPathSelector} bean is defined.
138 | * @param contextDataProvider helps providing additional context data in {@code input.context.data}.
139 | */
140 | public OPAAuthorizationManager(OPAClient opaClient, String opaPath, ContextDataProvider contextDataProvider) {
141 | opaProperties = new OPAProperties();
142 | this.opaClient = opaClient != null ? opaClient : defaultOPAClient();
143 | this.opaPath = opaPath;
144 | this.contextDataProvider = contextDataProvider;
145 | }
146 |
147 | private static OPAClient defaultOPAClient() {
148 | String opaUrl = OPAProperties.DEFAULT_URL;
149 | String opaUrlEnv = System.getenv("OPA_URL");
150 | if (opaUrlEnv != null) {
151 | opaUrl = opaUrlEnv;
152 | }
153 | return new OPAClient(opaUrl);
154 | }
155 |
156 | @Override
157 | public void verify(Supplier authenticationSupplier, RequestAuthorizationContext object) {
158 | OPAResponse opaResponse = opaRequest(authenticationSupplier, object);
159 | if (opaResponse == null) {
160 | throw new OPAAccessDeniedException("null response from policy");
161 | }
162 |
163 | boolean decision = opaResponse.getDecision();
164 | if (decision) {
165 | LOGGER.trace("access verified successfully");
166 | return;
167 | }
168 |
169 | String reason = opaResponse.getReasonForDecision(reasonKey);
170 | if (reason == null) {
171 | reason = "access denied by policy";
172 | }
173 | throw new OPAAccessDeniedException(reason, opaResponse);
174 | }
175 |
176 | @Override
177 | public AuthorizationDecision check(Supplier authenticationSupplier,
178 | RequestAuthorizationContext object) {
179 | OPAResponse opaResponse = opaRequest(authenticationSupplier, object);
180 | if (opaResponse == null) {
181 | LOGGER.trace("OPA provided a null response, default-denying access");
182 | return new OPAAuthorizationDecision(false, null);
183 | }
184 | return new OPAAuthorizationDecision(opaResponse.getDecision(), opaResponse);
185 | }
186 |
187 | /**
188 | * This method can be used to directly call OPA without generating an {@link AuthorizationDecision}, which can be
189 | * used to examine the OPA response. You should consider using the OPA Java SDK (which this library depends on)
190 | * directly rather than using this method, as it should not be needed during normal use.
191 | */
192 | public OPAResponse opaRequest(Supplier authenticationSupplier, RequestAuthorizationContext object) {
193 | Map input = makeRequestInput(authenticationSupplier, object);
194 | LOGGER.trace("OPA input (request body) is: {}", input);
195 | try {
196 | OPAResponse opaResponse;
197 | String selectedOPAPath = opaPathSelector != null
198 | ? opaPathSelector.selectPath(authenticationSupplier.get(), object, input) : opaPath;
199 | if (selectedOPAPath != null) {
200 | LOGGER.trace("OPA path is: {}", selectedOPAPath);
201 | opaResponse = opaClient.evaluate(selectedOPAPath, input, new TypeReference<>() {
202 | });
203 | } else {
204 | LOGGER.trace("Using default OPA path");
205 | opaResponse = opaClient.evaluate(input, new TypeReference<>() {
206 | });
207 | }
208 | LOGGER.trace("OPA response is: {}", opaResponse);
209 | return opaResponse;
210 | } catch (OPAException e) {
211 | LOGGER.error("caught exception from OPA client:", e);
212 | return null;
213 | }
214 | }
215 |
216 | private Map makeRequestInput(Supplier authenticationSupplier,
217 | RequestAuthorizationContext object) {
218 | HttpServletRequest request = object.getRequest();
219 |
220 | Object subjectId = null;
221 | Object subjectDetails = null;
222 | Collection extends GrantedAuthority> subjectAuthorities = null;
223 | Authentication authentication = authenticationSupplier.get();
224 | if (authentication != null) {
225 | subjectId = authentication.getPrincipal();
226 | subjectDetails = authentication.getDetails();
227 | subjectAuthorities = authentication.getAuthorities();
228 | }
229 | Map subject = new HashMap<>();
230 | nullablePut(subject, SUBJECT_TYPE, opaProperties.getRequest().getSubject().getType());
231 | nullablePut(subject, SUBJECT_ID, subjectId);
232 | nullablePut(subject, SUBJECT_DETAILS, subjectDetails);
233 | nullablePut(subject, SUBJECT_AUTHORITIES, subjectAuthorities);
234 | if (opaInputSubjectCustomizer != null) {
235 | subject = opaInputSubjectCustomizer.customize(authentication, object, subject);
236 | }
237 |
238 | String resourceId = request.getServletPath();
239 | Map resource = Map.ofEntries(
240 | entry(RESOURCE_TYPE, opaProperties.getRequest().getResource().getType()),
241 | entry(RESOURCE_ID, resourceId)
242 | );
243 | if (opaInputResourceCustomizer != null) {
244 | resource = opaInputResourceCustomizer.customize(authentication, object, resource);
245 | }
246 |
247 | String actionName = request.getMethod();
248 | String actionProtocol = request.getProtocol();
249 | Enumeration headerNamesEnumeration = request.getHeaderNames();
250 | Map actionHeaders = new HashMap<>();
251 | while (headerNamesEnumeration.hasMoreElements()) {
252 | String headerName = headerNamesEnumeration.nextElement();
253 | String headerValue = request.getHeader(headerName);
254 | if (headerValue != null) {
255 | actionHeaders.put(headerName, headerValue);
256 | }
257 | }
258 | Map action = Map.ofEntries(
259 | entry(ACTION_NAME, actionName),
260 | entry(ACTION_PROTOCOL, actionProtocol),
261 | entry(ACTION_HEADERS, actionHeaders)
262 | );
263 | if (opaInputActionCustomizer != null) {
264 | action = opaInputActionCustomizer.customize(authentication, object, action);
265 | }
266 |
267 | String contextHost = request.getRemoteHost();
268 | String contextIp = request.getRemoteAddr();
269 | Integer contextPort = request.getRemotePort();
270 | Map context = new HashMap<>();
271 | nullablePut(context, CONTEXT_TYPE, opaProperties.getRequest().getContext().getType());
272 | nullablePut(context, CONTEXT_HOST, contextHost);
273 | nullablePut(context, CONTEXT_IP, contextIp);
274 | nullablePut(context, CONTEXT_PORT, contextPort);
275 | if (contextDataProvider != null) {
276 | Object contextData = contextDataProvider.getContextData(authenticationSupplier, object);
277 | context.put(CONTEXT_DATA, contextData);
278 | }
279 | if (opaInputContextCustomizer != null) {
280 | context = opaInputContextCustomizer.customize(authentication, object, context);
281 | }
282 |
283 | Map input = context != null
284 | ? Map.of(SUBJECT, subject, RESOURCE, resource, ACTION, action, CONTEXT, context)
285 | : Map.of(SUBJECT, subject, RESOURCE, resource, ACTION, action);
286 |
287 | Optional.ofNullable(opaInputValidator).ifPresent(
288 | validator -> validator.validate(authentication, object, input));
289 |
290 | return input;
291 | }
292 |
293 | /**
294 | * If {@code nullableValue} is null, this function is a NO-OP, otherwise, it calls
295 | * {@code map}.put({@code key}, {@code nullableValue}).
296 | */
297 | private void nullablePut(Map map, String key, Object nullableValue) {
298 | Optional.ofNullable(nullableValue).ifPresent(value -> map.put(key, value));
299 | }
300 |
301 | @Autowired
302 | public void setOpaProperties(OPAProperties opaProperties) {
303 | this.opaProperties = opaProperties;
304 | reasonKey = opaProperties.getResponse().getContext().getReasonKey();
305 | }
306 |
307 | /**
308 | * Changes the "preferred" key where the access decision reason should be searched for in the {@link OPAResponse}.
309 | * A default value of {@value OPAProperties.Response.Context#DEFAULT_REASON_KEY} is used. If the selected
310 | * key is not present in the response, the key which sorts lexicographically first is used instead.
311 | */
312 | public void setReasonKey(String reasonKey) {
313 | this.reasonKey = reasonKey;
314 | }
315 | }
316 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/OPAPathSelector.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import org.springframework.security.core.Authentication;
4 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
5 |
6 | import java.util.Map;
7 |
8 | /**
9 | * Selects target OPA path based on {@link Authentication}, {@link RequestAuthorizationContext}, and input {@link Map}.
10 | */
11 | @FunctionalInterface
12 | public interface OPAPathSelector {
13 | String selectPath(Authentication authentication, RequestAuthorizationContext requestAuthorizationContext,
14 | Map opaInput);
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/OPAResponse.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import lombok.AllArgsConstructor;
4 | import lombok.Data;
5 | import lombok.NoArgsConstructor;
6 |
7 | /**
8 | * This class models the data to be returned from an OPA Spring Boot SDK policy. The
9 | * response schema is
10 | * compliant with the AuthZEN spec.
11 | */
12 | @Data
13 | @NoArgsConstructor
14 | @AllArgsConstructor
15 | public class OPAResponse {
16 |
17 | private boolean decision;
18 | private OPAResponseContext context;
19 |
20 | public boolean getDecision() {
21 | return decision;
22 | }
23 |
24 | /**
25 | * Wraps {@link OPAResponseContext#getReasonForDecision(String)}. If the context is omitted (which the spec
26 | * permits), then it returns null.
27 | */
28 | public String getReasonForDecision(String searchKey) {
29 | if (context == null) {
30 | return null;
31 | }
32 | return context.getReasonForDecision(searchKey);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/OPAResponseContext.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty;
4 | import lombok.AllArgsConstructor;
5 | import lombok.Data;
6 | import lombok.NoArgsConstructor;
7 | import org.springframework.security.authorization.AuthorizationDecision;
8 |
9 | import java.util.ArrayList;
10 | import java.util.Collections;
11 | import java.util.List;
12 | import java.util.Map;
13 |
14 | /**
15 | * This class models the data to be returned from an OPA policy under the {@code context} key.
16 | *
17 | * This corresponds to the {@code Context} object in the
18 | * AuthZen spec.
19 | */
20 | @Data
21 | @NoArgsConstructor
22 | @AllArgsConstructor
23 | public class OPAResponseContext {
24 |
25 | private String id;
26 | @JsonProperty("reason_admin")
27 | private Map reasonAdmin;
28 | @JsonProperty("reason_user")
29 | private Map reasonUser;
30 | /**
31 | * The extra {@code data} field allows for the OPA policy to pass back arbitrary structured data in addition to the
32 | * expected reason information.
33 | */
34 | private Map data;
35 |
36 | /**
37 | * This method selects an appropriate reason to use for creating {@link AuthorizationDecision}s. Currently, it will
38 | * select the search key if it is present in the {@code reasonUser}, and if not it will select the key which sorts
39 | * lexicographically first from the {@code reasonUser}. It will not consider data in the {@code reasonAdmin}.
40 | */
41 | public String getReasonForDecision(String searchKey) {
42 | if (reasonUser == null) {
43 | return null;
44 | }
45 |
46 | if (reasonUser.containsKey(searchKey)) {
47 | return reasonUser.get(searchKey);
48 | }
49 |
50 | List keys = new ArrayList<>(reasonUser.keySet());
51 | Collections.sort(keys);
52 | return !keys.isEmpty() ? reasonUser.get(keys.get(0)) : null;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/authorization/OPAAccessDeniedException.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import com.styra.opa.springboot.OPAResponse;
4 | import lombok.Getter;
5 | import org.springframework.security.access.AccessDeniedException;
6 |
7 | /**
8 | * Extends {@link AccessDeniedException} which conveys {@link OPAResponse}.
9 | */
10 | @Getter
11 | public class OPAAccessDeniedException extends AccessDeniedException {
12 |
13 | private OPAResponse opaResponse;
14 |
15 | public OPAAccessDeniedException(String message) {
16 | super(message);
17 | }
18 |
19 | public OPAAccessDeniedException(String message, OPAResponse opaResponse) {
20 | super(message);
21 | this.opaResponse = opaResponse;
22 | }
23 |
24 | public OPAAccessDeniedException(String message, Throwable cause, OPAResponse opaResponse) {
25 | super(message, cause);
26 | this.opaResponse = opaResponse;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/authorization/OPAAuthorizationDecision.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import com.styra.opa.springboot.OPAResponse;
4 | import lombok.Getter;
5 | import org.springframework.security.authorization.AuthorizationDecision;
6 |
7 | /**
8 | * Extends {@link AuthorizationDecision} which conveys {@link OPAResponse}.
9 | */
10 | @Getter
11 | public class OPAAuthorizationDecision extends AuthorizationDecision {
12 | private final OPAResponse opaResponse;
13 |
14 | public OPAAuthorizationDecision(boolean granted, OPAResponse opaResponse) {
15 | super(granted);
16 | this.opaResponse = opaResponse;
17 | }
18 |
19 | @Override
20 | public String toString() {
21 | return getClass().getSimpleName() + " [granted=" + isGranted() + ", opaResponse=" + opaResponse + "]";
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/authorization/OPAAuthorizationEventPublisher.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.context.ApplicationEventPublisher;
7 | import org.springframework.security.authorization.AuthorizationDecision;
8 | import org.springframework.security.authorization.AuthorizationEventPublisher;
9 | import org.springframework.security.authorization.SpringAuthorizationEventPublisher;
10 | import org.springframework.security.authorization.event.AuthorizationGrantedEvent;
11 | import org.springframework.security.core.Authentication;
12 |
13 | import java.util.function.Supplier;
14 |
15 | /**
16 | * Publishes OPA authorization granted/denied events. By default, only denied events are published. To change default
17 | * behavior, the following configuration properties could be used:
18 | *
19 | * -
20 | *
opa.authorization-event.denied.enabled
21 | *
22 | * -
23 | *
opa.authorization-event.granted.enabled
24 | *
25 | *
26 | * @see
27 | * Authorization Events
28 | */
29 | public class OPAAuthorizationEventPublisher implements AuthorizationEventPublisher {
30 | private static final Logger LOGGER = LoggerFactory.getLogger(OPAAuthorizationEventPublisher.class);
31 |
32 | private final ApplicationEventPublisher publisher;
33 | private final AuthorizationEventPublisher delegate;
34 | private final OPAProperties opaProperties;
35 |
36 | public OPAAuthorizationEventPublisher(ApplicationEventPublisher publisher, OPAProperties opaProperties) {
37 | this.publisher = publisher;
38 | this.delegate = new SpringAuthorizationEventPublisher(publisher);
39 | this.opaProperties = opaProperties;
40 | }
41 |
42 | @Override
43 | public void publishAuthorizationEvent(Supplier authentication, T object,
44 | AuthorizationDecision decision) {
45 | if (!(decision instanceof OPAAuthorizationDecision)) {
46 | return;
47 | }
48 |
49 | if (!decision.isGranted() && opaProperties.getAuthorizationEvent().getDenied().isEnabled()) {
50 | // Use `delegate` (instead of directly publishing denied events) to be forward-compatible with it:
51 | this.delegate.publishAuthorizationEvent(authentication, object, decision);
52 | LOGGER.trace("OPA AuthorizationDeniedEvent published.");
53 | return;
54 | }
55 | if (decision.isGranted() && opaProperties.getAuthorizationEvent().getGranted().isEnabled()) {
56 | AuthorizationGrantedEvent granted = new AuthorizationGrantedEvent<>(authentication, object, decision);
57 | this.publisher.publishEvent(granted);
58 | LOGGER.trace("OPA AuthorizationGrantedEvent published.");
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/autoconfigure/OPAAutoConfiguration.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure;
2 |
3 | import com.styra.opa.OPAClient;
4 | import com.styra.opa.springboot.OPAAuthorizationManager;
5 | import com.styra.opa.springboot.OPAPathSelector;
6 | import com.styra.opa.springboot.authorization.OPAAuthorizationEventPublisher;
7 | import com.styra.opa.springboot.input.OPAInputValidator;
8 | import org.springframework.boot.autoconfigure.AutoConfiguration;
9 | import org.springframework.boot.autoconfigure.AutoConfigureBefore;
10 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
12 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
13 | import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
14 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
15 | import org.springframework.context.ApplicationEventPublisher;
16 | import org.springframework.context.annotation.Bean;
17 |
18 | /**
19 | * {@link EnableAutoConfiguration Auto-configuration} for OPA authorization support.
20 | */
21 | @AutoConfiguration
22 | @EnableConfigurationProperties(OPAProperties.class)
23 | @AutoConfigureBefore(SecurityAutoConfiguration.class)
24 | @ConditionalOnClass(OPAClient.class)
25 | public class OPAAutoConfiguration {
26 |
27 | /**
28 | * Create an {@link OPAClient} bean using {@link OPAProperties#getUrl()}.
29 | */
30 | @Bean
31 | @ConditionalOnMissingBean(OPAClient.class)
32 | public OPAClient opaClient(OPAProperties opaProperties) {
33 | return new OPAClient(opaProperties.getUrl());
34 | }
35 |
36 | /**
37 | * Create an {@link OPAPathSelector} bean using {@link OPAProperties#getPath()}.
38 | */
39 | @Bean
40 | @ConditionalOnMissingBean
41 | public OPAPathSelector opaPathSelector(OPAProperties opaProperties) {
42 | return (authentication, requestAuthorizationContext, opaInput) -> opaProperties.getPath();
43 | }
44 |
45 | /**
46 | * Create an {@link OPAAuthorizationManager} bean using {@link OPAClient} bean and {@link OPAProperties#getPath()}.
47 | */
48 | @Bean
49 | @ConditionalOnMissingBean(OPAAuthorizationManager.class)
50 | public OPAAuthorizationManager opaAuthorizationManager(OPAClient opaClient, OPAProperties opaProperties) {
51 | return new OPAAuthorizationManager(opaClient, opaProperties.getPath());
52 | }
53 |
54 | /**
55 | * Create an {@link OPAInputValidator} to validate the OPA input's required fields before sending request to the
56 | * OPA server.
57 | */
58 | @Bean
59 | public OPAInputValidator opaInputValidator() {
60 | return new OPAInputValidator();
61 | }
62 |
63 | /**
64 | * Create an {@link OPAAuthorizationEventPublisher} to publish denied/granted authorization events.
65 | */
66 | @Bean
67 | public OPAAuthorizationEventPublisher opaAuthorizationEventPublisher(ApplicationEventPublisher publisher,
68 | OPAProperties opaProperties) {
69 | return new OPAAuthorizationEventPublisher(publisher, opaProperties);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/autoconfigure/OPAProperties.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure;
2 |
3 | import lombok.AllArgsConstructor;
4 | import lombok.Data;
5 | import lombok.NoArgsConstructor;
6 | import org.springframework.boot.context.properties.ConfigurationProperties;
7 |
8 | /**
9 | * Configuration properties for OPA authorization support.
10 | */
11 | @ConfigurationProperties(prefix = "opa")
12 | @Data
13 | @NoArgsConstructor
14 | @AllArgsConstructor
15 | public class OPAProperties {
16 | public static final String DEFAULT_URL = "http://localhost:8181";
17 |
18 | /**
19 | * URL of the OPA server. Default is {@value DEFAULT_URL}.
20 | */
21 | private String url = DEFAULT_URL;
22 | /**
23 | * Policy path in OPA. Default is null.
24 | */
25 | private String path;
26 | private Request request = new Request();
27 | private Response response = new Response();
28 | private AuthorizationEvent authorizationEvent = new AuthorizationEvent();
29 |
30 | @Data
31 | @NoArgsConstructor
32 | @AllArgsConstructor
33 | public static class Request {
34 |
35 | private Resource resource = new Resource();
36 | private Context context = new Context();
37 | private Subject subject = new Subject();
38 |
39 | @Data
40 | @NoArgsConstructor
41 | @AllArgsConstructor
42 | public static class Resource {
43 | public static final String DEFAULT_TYPE = "endpoint";
44 |
45 | /**
46 | * Type of the resource. Default is {@value DEFAULT_TYPE}.
47 | */
48 | private String type = DEFAULT_TYPE;
49 | }
50 |
51 | @Data
52 | @NoArgsConstructor
53 | @AllArgsConstructor
54 | public static class Context {
55 | public static final String DEFAULT_TYPE = "http";
56 |
57 | /**
58 | * Type of the context. Default is {@value DEFAULT_TYPE}.
59 | */
60 | private String type = DEFAULT_TYPE;
61 | }
62 |
63 | @Data
64 | @NoArgsConstructor
65 | @AllArgsConstructor
66 | public static class Subject {
67 | public static final String DEFAULT_TYPE = "java_authentication";
68 |
69 | /**
70 | * Type of the subject. Default is {@value DEFAULT_TYPE}.
71 | */
72 | private String type = DEFAULT_TYPE;
73 | }
74 | }
75 |
76 | @Data
77 | @NoArgsConstructor
78 | @AllArgsConstructor
79 | public static class Response {
80 |
81 | private Context context = new Context();
82 |
83 | @Data
84 | @NoArgsConstructor
85 | @AllArgsConstructor
86 | public static class Context {
87 | public static final String DEFAULT_REASON_KEY = "en";
88 |
89 | /**
90 | * Key to search for decision reasons in the response. Default is {@value DEFAULT_REASON_KEY}.
91 | *
92 | * @see AuthZEN Reason Field
93 | */
94 | private String reasonKey = DEFAULT_REASON_KEY;
95 | }
96 | }
97 |
98 | @Data
99 | @NoArgsConstructor
100 | @AllArgsConstructor
101 | public static class AuthorizationEvent {
102 | private AuthorizationEventType denied = new AuthorizationEventType(true);
103 | private AuthorizationEventType granted = new AuthorizationEventType(false);
104 | }
105 |
106 | @Data
107 | @NoArgsConstructor
108 | @AllArgsConstructor
109 | public static class AuthorizationEventType {
110 | private boolean enabled;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/InputConstants.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | public final class InputConstants {
4 |
5 | public static final String SUBJECT = "subject";
6 | public static final String RESOURCE = "resource";
7 | public static final String ACTION = "action";
8 | public static final String CONTEXT = "context";
9 | public static final String SUBJECT_TYPE = "type";
10 | public static final String SUBJECT_ID = "id";
11 | public static final String SUBJECT_DETAILS = "details";
12 | public static final String SUBJECT_AUTHORITIES = "authorities";
13 | public static final String RESOURCE_TYPE = "type";
14 | public static final String RESOURCE_ID = "id";
15 | public static final String ACTION_NAME = "name";
16 | public static final String ACTION_PROTOCOL = "protocol";
17 | public static final String ACTION_HEADERS = "headers";
18 | public static final String CONTEXT_TYPE = "type";
19 | public static final String CONTEXT_HOST = "host";
20 | public static final String CONTEXT_IP = "ip";
21 | public static final String CONTEXT_PORT = "port";
22 | public static final String CONTEXT_DATA = "data";
23 |
24 | private InputConstants() {
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/OPAInputActionCustomizer.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import org.springframework.security.core.Authentication;
5 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
6 |
7 | import java.util.Map;
8 |
9 | /**
10 | * By defining a bean which implements this interface, clients could customize OPA {@code input.action}.
11 | */
12 | @FunctionalInterface
13 | public interface OPAInputActionCustomizer {
14 |
15 | /**
16 | * Customizes {@code action} {@link Map}.
17 | * @param action contains:
18 | *
19 | * - {@value InputConstants#ACTION_NAME}: {@link HttpServletRequest#getMethod()}
20 | * - {@value InputConstants#ACTION_PROTOCOL}: {@link HttpServletRequest#getProtocol()}
21 | * - {@value InputConstants#ACTION_HEADERS}: {@link HttpServletRequest} headers
22 | *
23 | * @return should at least contains this key:
24 | *
25 | * - {@value InputConstants#ACTION_NAME}
26 | *
27 | */
28 | Map customize(Authentication authentication,
29 | RequestAuthorizationContext requestAuthorizationContext, Map action);
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/OPAInputContextCustomizer.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import com.styra.opa.springboot.ContextDataProvider;
4 | import com.styra.opa.springboot.OPAAuthorizationManager;
5 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
6 | import jakarta.servlet.http.HttpServletRequest;
7 | import org.springframework.security.core.Authentication;
8 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
9 |
10 | import java.util.Map;
11 | import java.util.function.Supplier;
12 |
13 | /**
14 | * By defining a bean which implements this interface, clients could customize OPA {@code input.context}.
15 | */
16 | @FunctionalInterface
17 | public interface OPAInputContextCustomizer {
18 |
19 | /**
20 | * Customizes {@code context} {@link Map}. This method could return {@code null}.
21 | * @param context contains:
22 | *
23 | * - {@value InputConstants#CONTEXT_TYPE}: {@value OPAProperties.Request.Context#DEFAULT_TYPE}
24 | * (configurable via {@code opa.request.context.type} property)
25 | * - {@value InputConstants#CONTEXT_HOST}: {@link HttpServletRequest#getRemoteHost()}
26 | * - {@value InputConstants#CONTEXT_IP}: {@link HttpServletRequest#getRemoteAddr()}
27 | * - {@value InputConstants#CONTEXT_PORT}: {@link HttpServletRequest#getRemotePort()}
28 | * - {@value InputConstants#CONTEXT_DATA}:
29 | * {@link ContextDataProvider#getContextData(Supplier, RequestAuthorizationContext)} (if a
30 | * {@link ContextDataProvider} gets passed to an {@link OPAAuthorizationManager} constructor)
31 | *
32 | * @return if not null, should at least contains this key:
33 | *
34 | * - {@value InputConstants#CONTEXT_TYPE}
35 | *
36 | */
37 | Map customize(Authentication authentication,
38 | RequestAuthorizationContext requestAuthorizationContext, Map context);
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/OPAInputResourceCustomizer.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import jakarta.servlet.http.HttpServletRequest;
5 | import org.springframework.security.core.Authentication;
6 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
7 |
8 | import java.util.Map;
9 |
10 | /**
11 | * By defining a bean which implements this interface, clients could customize OPA {@code input.resource}.
12 | */
13 | @FunctionalInterface
14 | public interface OPAInputResourceCustomizer {
15 |
16 | /**
17 | * Customizes {@code resource} {@link Map}.
18 | * @param resource contains:
19 | *
20 | * - {@value InputConstants#RESOURCE_TYPE}: {@value OPAProperties.Request.Resource#DEFAULT_TYPE}
21 | * (configurable via {@code opa.request.resource.type} property)
22 | * - {@value InputConstants#RESOURCE_ID}: {@link HttpServletRequest#getServletPath()}
23 | *
24 | * @return should at least contains these keys:
25 | *
26 | * - {@value InputConstants#RESOURCE_TYPE}
27 | * - {@value InputConstants#RESOURCE_ID}
28 | *
29 | */
30 | Map customize(Authentication authentication,
31 | RequestAuthorizationContext requestAuthorizationContext,
32 | Map resource);
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/OPAInputSubjectCustomizer.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import org.springframework.security.core.Authentication;
5 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
6 |
7 | import java.util.Map;
8 |
9 | /**
10 | * By defining a bean which implements this interface, clients could customize OPA {@code input.subject}.
11 | */
12 | @FunctionalInterface
13 | public interface OPAInputSubjectCustomizer {
14 |
15 | /**
16 | * Customizes {@code subject} {@link Map}.
17 | * @param subject contains:
18 | *
19 | * - {@value InputConstants#SUBJECT_TYPE}: {@value OPAProperties.Request.Subject#DEFAULT_TYPE}
20 | * (configurable via {@code opa.request.subject.type} property)
21 | * - {@value InputConstants#SUBJECT_ID}: {@link Authentication#getPrincipal()}
22 | * - {@value InputConstants#SUBJECT_DETAILS}: {@link Authentication#getDetails()}
23 | * - {@value InputConstants#SUBJECT_AUTHORITIES}: {@link Authentication#getAuthorities()}
24 | *
25 | * @return should at least contains these keys:
26 | *
27 | * - {@value InputConstants#SUBJECT_TYPE}
28 | * - {@value InputConstants#SUBJECT_ID}
29 | *
30 | */
31 | Map customize(Authentication authentication,
32 | RequestAuthorizationContext requestAuthorizationContext, Map subject);
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/java/com/styra/opa/springboot/input/OPAInputValidator.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.springframework.security.access.AccessDeniedException;
4 | import org.springframework.security.core.Authentication;
5 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
6 |
7 | import java.util.Map;
8 |
9 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
10 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
11 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
12 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
13 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
14 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_ID;
15 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
16 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
17 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_ID;
18 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
19 | import static java.lang.String.format;
20 |
21 | /**
22 | * Makes sure that mandatory OPA {@code input} properties are available.
23 | */
24 | public final class OPAInputValidator {
25 |
26 | public static final String EXCEPTION_MESSAGE_TEMPLATE = "OPA input must contain '%s.%s'";
27 |
28 | /**
29 | * Validates {@code input} {@link Map}.
30 | * @param input which will be passed to the OPA server as request body.
31 | * @throws AccessDeniedException if {code input} does not contain any of these keys:
32 | *
33 | * - {@value InputConstants#SUBJECT}.{@value InputConstants#SUBJECT_TYPE}
34 | * - {@value InputConstants#SUBJECT}.{@value InputConstants#SUBJECT_ID}
35 | * - {@value InputConstants#RESOURCE}.{@value InputConstants#RESOURCE_TYPE}
36 | * - {@value InputConstants#RESOURCE}.{@value InputConstants#RESOURCE_ID}
37 | * - {@value InputConstants#ACTION}.{@value InputConstants#ACTION_NAME}
38 | * - If {@value InputConstants#CONTEXT} is available, {@value InputConstants#CONTEXT}.
39 | * {@value InputConstants#CONTEXT_TYPE}
40 | *
41 | */
42 | public void validate(Authentication authentication, RequestAuthorizationContext requestAuthorizationContext,
43 | Map input) throws AccessDeniedException {
44 | validateKey(input, SUBJECT, SUBJECT_TYPE);
45 | validateKey(input, SUBJECT, SUBJECT_ID);
46 | validateKey(input, RESOURCE, RESOURCE_TYPE);
47 | validateKey(input, RESOURCE, RESOURCE_ID);
48 | validateKey(input, ACTION, ACTION_NAME);
49 | if (input.get(CONTEXT) != null) {
50 | validateKey(input, CONTEXT, CONTEXT_TYPE);
51 | }
52 | }
53 |
54 | @SuppressWarnings("unchecked")
55 | private void validateKey(Map input, String entity, String key) {
56 | Map entityMap = (Map) input.get(entity);
57 | if (entityMap.get(key) == null) {
58 | throw new AccessDeniedException(format(EXCEPTION_MESSAGE_TEMPLATE, entity, key));
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
--------------------------------------------------------------------------------
1 | com.styra.opa.springboot.autoconfigure.OPAAutoConfiguration
2 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/BaseIntegrationTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import com.fasterxml.jackson.databind.JsonNode;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 | import com.styra.opa.springboot.autoconfigure.OPAAutoConfiguration;
6 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
7 | import jakarta.servlet.http.HttpServletRequest;
8 | import org.junit.jupiter.api.AfterEach;
9 | import org.junit.jupiter.api.BeforeEach;
10 | import org.mockito.Mock;
11 | import org.mockito.MockitoAnnotations;
12 | import org.slf4j.LoggerFactory;
13 | import org.springframework.boot.test.context.SpringBootTest;
14 | import org.springframework.security.core.Authentication;
15 | import org.springframework.security.core.GrantedAuthority;
16 | import org.springframework.security.core.authority.SimpleGrantedAuthority;
17 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
18 | import org.springframework.security.web.authentication.WebAuthenticationDetails;
19 | import org.springframework.test.context.DynamicPropertyRegistry;
20 | import org.springframework.test.context.DynamicPropertySource;
21 | import org.testcontainers.containers.BindMode;
22 | import org.testcontainers.containers.GenericContainer;
23 | import org.testcontainers.containers.output.Slf4jLogConsumer;
24 | import org.testcontainers.images.builder.ImageFromDockerfile;
25 | import org.testcontainers.junit.jupiter.Container;
26 | import org.testcontainers.junit.jupiter.Testcontainers;
27 |
28 | import java.util.ArrayList;
29 | import java.util.Arrays;
30 | import java.util.Collection;
31 | import java.util.Collections;
32 | import java.util.HashMap;
33 | import java.util.HashSet;
34 | import java.util.List;
35 | import java.util.Map;
36 | import java.util.Objects;
37 | import java.util.Set;
38 | import java.util.function.Supplier;
39 |
40 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
41 | import static com.styra.opa.springboot.input.InputConstants.ACTION_HEADERS;
42 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
43 | import static com.styra.opa.springboot.input.InputConstants.ACTION_PROTOCOL;
44 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
45 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_DATA;
46 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_HOST;
47 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_IP;
48 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_PORT;
49 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
50 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
51 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_ID;
52 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
53 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
54 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_ID;
55 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
56 | import static java.util.Map.entry;
57 | import static org.mockito.ArgumentMatchers.anyString;
58 | import static org.mockito.Mockito.doReturn;
59 | import static org.mockito.Mockito.mock;
60 | import static org.mockito.Mockito.when;
61 |
62 | @SpringBootTest(classes = OPAAutoConfiguration.class)
63 | @Testcontainers
64 | public abstract class BaseIntegrationTest {
65 | protected static final int OPA_PORT = 8181;
66 | /**
67 | * Besides OPA default port ({@code opaPort}), OPA is exposed under {@code localhost:8282/customprefix} using
68 | * Nginx.
69 | * Nginx configuration is provided in {@code src/test/resources/nginx.conf} file.
70 | */
71 | protected static final int ALT_PORT = 8282;
72 | protected static final Map HEADERS = Map.ofEntries(entry("Authorization", "Bearer supersecret"));
73 |
74 | //CHECKSTYLE:OFF
75 | /*
76 | * Checkstyle is disabled here to allow protected fields. Morover, Checkstyle wants opaContainer to 'be private and
77 | * have accessor methods', which seems pointless and will probably mess up test containers.
78 | */
79 | @Mock
80 | protected Supplier authenticationSupplier;
81 | @Mock
82 | protected RequestAuthorizationContext context;
83 | @Mock
84 | protected HttpServletRequest httpServletRequest;
85 |
86 | @Container
87 | public static GenericContainer> opaContainer = new GenericContainer<>(
88 | new ImageFromDockerfile()
89 | // .withFileFromClasspath(path_in_build_context, path_in_resources_dir)
90 | .withFileFromClasspath("Dockerfile", "opa.Dockerfile")
91 | .withFileFromClasspath("nginx.conf", "nginx.conf")
92 | .withFileFromClasspath("entrypoint.sh", "entrypoint.sh")
93 | )
94 | .withExposedPorts(OPA_PORT, ALT_PORT)
95 | .withFileSystemBind("./testdata/simple", "/policy", BindMode.READ_ONLY)
96 | .withCommand("run -s --authentication=token --authorization=basic --bundle /policy --addr=0.0.0.0:" + OPA_PORT)
97 | .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(OPAAuthorizationManagerTest.class)));
98 |
99 | protected final ObjectMapper objectMapper = new ObjectMapper();
100 | protected String address;
101 | protected String altAddress;
102 | //CHECKSTYLE:ON
103 |
104 | @DynamicPropertySource
105 | static void registerOpaProperties(DynamicPropertyRegistry registry) {
106 | registry.add("opa.url",
107 | () -> String.format("http://%s:%d", opaContainer.getHost(), opaContainer.getMappedPort(OPA_PORT)));
108 | }
109 |
110 | @BeforeEach
111 | public void setUp() {
112 | MockitoAnnotations.openMocks(this);
113 | when(context.getRequest()).thenReturn(httpServletRequest);
114 |
115 | Map mockHeaders = new HashMap<>();
116 | mockHeaders.put("UnitTestHeader", "123abc");
117 | when(httpServletRequest.getHeaderNames()).thenReturn(Collections.enumeration(mockHeaders.keySet()));
118 | when(httpServletRequest.getHeader(anyString())).thenAnswer(invocation -> {
119 | String headerName = invocation.getArgument(0);
120 | return mockHeaders.get(headerName);
121 | });
122 |
123 | when(httpServletRequest.getServletPath()).thenReturn("unit/test");
124 | when(httpServletRequest.getMethod()).thenReturn("GET");
125 | when(httpServletRequest.getProtocol()).thenReturn("HTTP/1.1");
126 | when(httpServletRequest.getRemoteHost()).thenReturn("example.com");
127 | when(httpServletRequest.getRemoteAddr()).thenReturn("192.0.2.123");
128 |
129 | address = "http://" + opaContainer.getHost() + ":" + opaContainer.getMappedPort(OPA_PORT);
130 | altAddress = "http://" + opaContainer.getHost() + ":" + opaContainer.getMappedPort(ALT_PORT) + "/customprefix";
131 | }
132 |
133 | @AfterEach
134 | public void dumpLogs() {
135 | System.out.println("==== container logs from OPA container ====");
136 | System.out.println(opaContainer.getLogs());
137 | }
138 |
139 | protected Authentication createMockAuthentication() {
140 | var mockAuth = mock(Authentication.class);
141 | when(mockAuth.getPrincipal()).thenReturn("testuser");
142 | when(mockAuth.getCredentials()).thenReturn("letmein");
143 |
144 | var details = new WebAuthenticationDetails(httpServletRequest);
145 | when(mockAuth.getDetails()).thenReturn(details);
146 |
147 | var authority1 = new SimpleGrantedAuthority("ROLE_USER");
148 | var authority2 = new SimpleGrantedAuthority("ROLE_ADMIN");
149 | Collection authorities = Arrays.asList(authority1, authority2);
150 | doReturn(authorities).when(mockAuth).getAuthorities();
151 |
152 | when(mockAuth.isAuthenticated()).thenReturn(true);
153 |
154 | return mockAuth;
155 | }
156 |
157 | protected Map createNullMockAuthOPAInput() {
158 | return Map.ofEntries(
159 | entry(ACTION, Map.ofEntries(
160 | entry(ACTION_HEADERS, Map.ofEntries(
161 | entry("UnitTestHeader", "123abc")
162 | )),
163 | entry(ACTION_NAME, "GET"),
164 | entry(ACTION_PROTOCOL, "HTTP/1.1")
165 | )),
166 | entry(CONTEXT, Map.ofEntries(
167 | entry(CONTEXT_HOST, "example.com"),
168 | entry(CONTEXT_IP, "192.0.2.123"),
169 | entry(CONTEXT_PORT, 0),
170 | entry(CONTEXT_TYPE, OPAProperties.Request.Context.DEFAULT_TYPE),
171 | entry(CONTEXT_DATA, Map.ofEntries(
172 | entry("hello", "world")
173 | ))
174 | )),
175 | entry(RESOURCE, Map.ofEntries(
176 | entry(RESOURCE_ID, "unit/test"),
177 | entry(RESOURCE_TYPE, OPAProperties.Request.Resource.DEFAULT_TYPE)
178 | )),
179 | entry(SUBJECT, Map.ofEntries(
180 | entry(SUBJECT_ID, "testuser"),
181 | entry(SUBJECT_TYPE, OPAProperties.Request.Subject.DEFAULT_TYPE)
182 | ))
183 | );
184 | }
185 |
186 | /**
187 | * This is used to create a mock {@link Authentication} object where most of the fields are null, to resolve
188 | * exceptions when optional fields are omitted.
189 | */
190 | protected Authentication createNullMockAuthentication() {
191 | var mockAuth = mock(Authentication.class);
192 | when(mockAuth.getPrincipal()).thenReturn("testuser");
193 | when(mockAuth.getCredentials()).thenReturn(null);
194 | when(mockAuth.getDetails()).thenReturn(null);
195 |
196 | doReturn(null).when(mockAuth).getAuthorities();
197 |
198 | when(mockAuth.isAuthenticated()).thenReturn(true);
199 |
200 | return mockAuth;
201 | }
202 |
203 | /**
204 | * Convert the value to JSON and then retrieve the value at the specified path.
205 | * Note that this does stringify all JSON types, including null, so there could be some slight shadowing problems.
206 | */
207 | protected String jsonGet(Object root, String path) {
208 | var jsonRoot = objectMapper.valueToTree(root);
209 | return jsonRoot.at(path).asText();
210 | }
211 |
212 | /**
213 | * List all JSON paths found under the object.
214 | */
215 | protected List jsonList(Object root) {
216 | var jsonRoot = objectMapper.valueToTree(root);
217 | List paths = new ArrayList<>();
218 | jsonList(jsonRoot, "", paths);
219 | return paths;
220 | }
221 |
222 | protected void jsonList(JsonNode node, String currentPath, List paths) {
223 | if (node.isValueNode()) {
224 | paths.add(currentPath);
225 | } else if (node.isObject()) {
226 | node.fields().forEachRemaining(entry -> {
227 | String fieldName = entry.getKey();
228 | JsonNode childNode = entry.getValue();
229 | jsonList(childNode, currentPath + "/" + fieldName, paths);
230 | });
231 | } else if (node.isArray()) {
232 | for (int i = 0; i < node.size(); i++) {
233 | jsonList(node.get(i), currentPath + "/" + i, paths);
234 | }
235 | }
236 | }
237 |
238 | /**
239 | * Create a human-readable list of differences between two objects. The possible paths are enumerated with
240 | * {@link #jsonList(Object)}, and they are retrieved using {@link #jsonGet(Object, String)}. This does mean that
241 | * all values are compared stringly.
242 | */
243 | protected List jsonDiff(Object rootA, Object rootB) {
244 | List pathsA = jsonList(rootA);
245 | List pathsB = jsonList(rootB);
246 | Set pathSet = new HashSet<>(pathsA);
247 | pathSet.addAll(pathsB);
248 | List paths = new ArrayList<>(pathSet);
249 |
250 | List results = new ArrayList<>();
251 |
252 | for (int i = 0; i < paths.size(); i++) {
253 | String valA = jsonGet(rootA, paths.get(i));
254 | String valB = jsonGet(rootB, paths.get(i));
255 | if (!Objects.equals(valA, valB)) {
256 | results.add(String.format("%s: %s =/= %s", paths.get(i), valA, valB));
257 | }
258 | }
259 |
260 | return results;
261 | }
262 |
263 | protected String jsonPretty(Object root) {
264 | try {
265 | return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root);
266 | //CHECKSTYLE:OFF
267 | } catch (Exception e) {
268 | //CHECKSTYLE:ON
269 | return String.format("failed to pretty print JSON: %s", e);
270 | }
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/OPAAuthorizationManagerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import com.styra.opa.OPAClient;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.security.access.AccessDeniedException;
6 | import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
7 |
8 | import java.net.URI;
9 | import java.net.http.HttpClient;
10 | import java.net.http.HttpRequest;
11 | import java.net.http.HttpResponse;
12 | import java.util.HashMap;
13 | import java.util.List;
14 | import java.util.Map;
15 | import java.util.function.Supplier;
16 |
17 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
18 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_AUTHORITIES;
19 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_DETAILS;
20 | import static java.util.Map.entry;
21 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
22 | import static org.junit.jupiter.api.Assertions.assertEquals;
23 | import static org.junit.jupiter.api.Assertions.assertFalse;
24 | import static org.junit.jupiter.api.Assertions.assertNull;
25 | import static org.junit.jupiter.api.Assertions.assertThrows;
26 | import static org.junit.jupiter.api.Assertions.assertTrue;
27 | import static org.mockito.Mockito.when;
28 |
29 | class OPAAuthorizationManagerTest extends BaseIntegrationTest {
30 |
31 | /**
32 | * This test just makes sure that we can reach the OPAClient health endpoint and that it returns the expected body.
33 | */
34 | @Test
35 | public void testOPAHealth() {
36 | var client = HttpClient.newHttpClient();
37 | var request = HttpRequest.newBuilder().uri(URI.create(address + "/health")).build();
38 | HttpResponse response = null;
39 |
40 | try {
41 | response = client.send(request, HttpResponse.BodyHandlers.ofString());
42 | //CHECKSTYLE:OFF
43 | } catch (Exception e) {
44 | //CHECKSTYLE:ON
45 | System.out.println("exception: " + e);
46 | assertNull(e);
47 | }
48 |
49 | var responseBody = response.body();
50 | assertEquals("{}\n", responseBody);
51 | }
52 |
53 | /**
54 | * This makes sure that we can also successfully reach the OPA health API on the "alternate", reverse-proxy based
55 | * OPA that has a URL prefix.
56 | */
57 | @Test
58 | public void testOPAHealthAlternate() {
59 | var client = HttpClient.newHttpClient();
60 | var request = HttpRequest.newBuilder().uri(URI.create(altAddress + "/health")).build();
61 | HttpResponse response = null;
62 |
63 | try {
64 | response = client.send(request, HttpResponse.BodyHandlers.ofString());
65 | //CHECKSTYLE:OFF
66 | } catch (Exception e) {
67 | //CHECKSTYLE:ON
68 | System.out.println("exception: " + e);
69 | assertNull(e);
70 | }
71 |
72 | var responseBody = response.body();
73 | assertEquals("{}\n", responseBody);
74 | }
75 |
76 | /**
77 | * Make sure that with a simple always-allow rule, we allow all requests, using
78 | * {@link OPAAuthorizationManager#check(Supplier, RequestAuthorizationContext)}.
79 | */
80 | @Test
81 | public void testOPAAuthorizationManagerSimpleAllow() {
82 | var mockAuth = createMockAuthentication();
83 | when(authenticationSupplier.get()).thenReturn(mockAuth);
84 | var opaClient = new OPAClient(address, HEADERS);
85 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/decision_always_true");
86 | var actual = opaAuthorizationManager.check(authenticationSupplier, context);
87 | assertTrue(actual.isGranted());
88 | }
89 |
90 | /**
91 | * Make sure that with a simple always-deny rule, we deny all requests, using
92 | * {@link OPAAuthorizationManager#check(Supplier, RequestAuthorizationContext)}.
93 | */
94 | @Test
95 | public void testOPAAuthorizationManagerSimpleDeny() {
96 | var mockAuth = createMockAuthentication();
97 | when(authenticationSupplier.get()).thenReturn(mockAuth);
98 | var opaClient = new OPAClient(address, HEADERS);
99 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/decision_always_false");
100 | var actual = opaAuthorizationManager.check(authenticationSupplier, context);
101 | assertFalse(actual.isGranted());
102 | }
103 |
104 | /**
105 | * Make sure that with a simple always-allow rule, we allow all requests, using
106 | * {@link OPAAuthorizationManager#verify(Supplier, RequestAuthorizationContext)}.
107 | */
108 | @Test
109 | public void testOPAAuthorizationManagerSimpleAllowVerify() {
110 | var mockAuth = createMockAuthentication();
111 | when(authenticationSupplier.get()).thenReturn(mockAuth);
112 | var opaClient = new OPAClient(address, HEADERS);
113 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/decision_always_true");
114 | assertDoesNotThrow(() -> opaAuthorizationManager.verify(authenticationSupplier, context));
115 | }
116 |
117 | /**
118 | * Make sure that with a simple always-deny rule, we deny all requests, using
119 | * {@link OPAAuthorizationManager#verify(Supplier, RequestAuthorizationContext)}.
120 | */
121 | @Test
122 | public void testOPAAuthorizationManagerSimpleDenyVerify() {
123 | var mockAuth = createMockAuthentication();
124 | when(authenticationSupplier.get()).thenReturn(mockAuth);
125 | var opaClient = new OPAClient(address, HEADERS);
126 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/decision_always_false");
127 | assertThrows(AccessDeniedException.class,
128 | () -> opaAuthorizationManager.verify(authenticationSupplier, context));
129 | }
130 |
131 | /**
132 | * By reading back the input, we can make sure the OPA input has the right structure and content.
133 | */
134 | @Test
135 | @SuppressWarnings("unchecked")
136 | public void testOPAAuthorizationManagerEcho() {
137 | Map expectedResponseContextData = new HashMap<>(createNullMockAuthOPAInput());
138 | Map subject = new HashMap<>((Map) expectedResponseContextData.get(SUBJECT));
139 | subject.putAll(Map.ofEntries(
140 | entry(SUBJECT_AUTHORITIES, List.of(
141 | Map.ofEntries(entry("authority", "ROLE_USER")),
142 | Map.ofEntries(entry("authority", "ROLE_ADMIN"))
143 | )),
144 | entry(SUBJECT_DETAILS, Map.ofEntries(
145 | entry("remoteAddress", "192.0.2.123"),
146 | entry("sessionId", "null")
147 | ))
148 | ));
149 | expectedResponseContextData.put(SUBJECT, subject);
150 |
151 | var expectedResponseContext = new OPAResponseContext();
152 | expectedResponseContext.setReasonUser(Map.ofEntries(
153 | entry("en", "echo rule always allows"),
154 | entry("other", "other reason key")
155 | ));
156 | expectedResponseContext.setId("0");
157 | expectedResponseContext.setData(expectedResponseContextData);
158 |
159 | var expectedResponse = new OPAResponse();
160 | expectedResponse.setDecision(true);
161 | expectedResponse.setContext(expectedResponseContext);
162 |
163 | var contextDataProvider = new ConstantContextDataProvider(Map.ofEntries(
164 | entry("hello", "world")
165 | ));
166 | var mockAuth = createMockAuthentication();
167 | when(authenticationSupplier.get()).thenReturn(mockAuth);
168 | var opaClient = new OPAClient(address, HEADERS);
169 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/echo", contextDataProvider);
170 | var actualResponse = opaAuthorizationManager.opaRequest(authenticationSupplier, context);
171 |
172 | assertEquals(expectedResponse.getDecision(), actualResponse.getDecision());
173 | assertEquals(expectedResponse.getContext().getId(), actualResponse.getContext().getId());
174 | assertEquals(expectedResponse.getContext().getReasonUser(), actualResponse.getContext().getReasonUser());
175 |
176 | var dataDiffs = jsonDiff(expectedResponse.getContext().getData(), actualResponse.getContext().getData());
177 |
178 | System.out.printf("#### expected context data\n%s\n", jsonPretty(expectedResponseContextData));
179 | System.out.printf("#### actual context data\n%s\n", jsonPretty(actualResponse.getContext().getData()));
180 |
181 | for (String dataDiff : dataDiffs) {
182 | System.out.printf("diff mismatch: %s\n", dataDiff);
183 | }
184 |
185 | assertEquals(0, dataDiffs.size());
186 | assertEquals("echo rule always allows", actualResponse.getReasonForDecision("en"));
187 | assertEquals("other reason key", actualResponse.getReasonForDecision("other"));
188 | assertEquals("echo rule always allows", actualResponse.getReasonForDecision("nonexistant"));
189 | }
190 |
191 | /**
192 | * By reading back the input, we can make sure the OPA input has the right structure and content.
193 | */
194 | @Test
195 | public void testOPAAuthorizationManagerNullMetadata() {
196 | Map expectedResponseContextData = createNullMockAuthOPAInput();
197 |
198 | var expectedResponseContext = new OPAResponseContext();
199 | expectedResponseContext.setReasonUser(Map.ofEntries(
200 | entry("en", "echo rule always allows"),
201 | entry("other", "other reason key")
202 | ));
203 | expectedResponseContext.setId("0");
204 | expectedResponseContext.setData(expectedResponseContextData);
205 |
206 | var expectedResponse = new OPAResponse();
207 | expectedResponse.setDecision(true);
208 | expectedResponse.setContext(expectedResponseContext);
209 |
210 | var contextDataProvider = new ConstantContextDataProvider(Map.ofEntries(
211 | entry("hello", "world")
212 | ));
213 | var mockAuth = createNullMockAuthentication();
214 | when(authenticationSupplier.get()).thenReturn(mockAuth);
215 | var opaClient = new OPAClient(address, HEADERS);
216 | var opaAuthorizationManager = new OPAAuthorizationManager(opaClient, "policy/echo", contextDataProvider);
217 | var actualResponse = opaAuthorizationManager.opaRequest(authenticationSupplier, context);
218 |
219 | assertEquals(expectedResponse.getDecision(), actualResponse.getDecision());
220 | assertEquals(expectedResponse.getContext().getId(), actualResponse.getContext().getId());
221 | assertEquals(expectedResponse.getContext().getReasonUser(), actualResponse.getContext().getReasonUser());
222 |
223 | var dataDiffs = jsonDiff(expectedResponse.getContext().getData(),
224 | actualResponse.getContext().getData());
225 |
226 | System.out.printf("#### expected context data\n%s\n", jsonPretty(expectedResponseContextData));
227 | System.out.printf("#### actual context data\n%s\n", jsonPretty(actualResponse.getContext().getData()));
228 |
229 | for (String dataDiff : dataDiffs) {
230 | System.out.printf("diff mismatch: %s\n", dataDiff);
231 | }
232 |
233 | assertEquals(0, dataDiffs.size());
234 | assertEquals("echo rule always allows", actualResponse.getReasonForDecision("en"));
235 | assertEquals("other reason key", actualResponse.getReasonForDecision("other"));
236 | assertEquals("echo rule always allows", actualResponse.getReasonForDecision("nonexistant"));
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/OPAPathSelectorTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot;
2 |
3 | import com.styra.opa.OPAClient;
4 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
5 | import org.junit.jupiter.api.Nested;
6 | import org.junit.jupiter.api.Test;
7 | import org.springframework.beans.factory.annotation.Autowired;
8 | import org.springframework.boot.test.context.TestConfiguration;
9 | import org.springframework.context.annotation.Bean;
10 | import org.springframework.context.annotation.Import;
11 | import org.springframework.core.Ordered;
12 | import org.springframework.core.annotation.Order;
13 | import org.springframework.security.access.AccessDeniedException;
14 |
15 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
16 | import static org.junit.jupiter.api.Assertions.assertThrows;
17 | import static org.mockito.Mockito.when;
18 |
19 | @Nested
20 | @Import(OPAPathSelectorTest.CustomOPAConfig.class)
21 | public class OPAPathSelectorTest extends BaseIntegrationTest {
22 |
23 | @Autowired
24 | private OPAAuthorizationManager opaAuthorizationManager;
25 |
26 | @Test
27 | public void testOPAPathSelectorAlwaysTruePolicy() {
28 | var mockAuth = createMockAuthentication();
29 | when(mockAuth.getPrincipal()).thenReturn("testuser_allowed");
30 | when(authenticationSupplier.get()).thenReturn(mockAuth);
31 | assertDoesNotThrow(() -> opaAuthorizationManager.verify(authenticationSupplier, context));
32 | }
33 |
34 | @Test
35 | public void testOPAPathSelectorAlwaysFalsePolicy() {
36 | var mockAuth = createMockAuthentication();
37 | when(mockAuth.getPrincipal()).thenReturn("testuser_denied");
38 | when(authenticationSupplier.get()).thenReturn(mockAuth);
39 | assertThrows(AccessDeniedException.class,
40 | () -> opaAuthorizationManager.verify(authenticationSupplier, context));
41 | }
42 |
43 | @Order(Ordered.HIGHEST_PRECEDENCE)
44 | @TestConfiguration
45 | public static class CustomOPAConfig {
46 | @Bean
47 | public OPAClient opaClient(OPAProperties opaProperties) {
48 | return new OPAClient(opaProperties.getUrl(), HEADERS);
49 | }
50 |
51 | @Bean
52 | public OPAPathSelector opaPathSelector() {
53 | return (authentication, requestAuthorizationContext, opaInput) -> {
54 | if (authentication.getPrincipal().equals("testuser_allowed")) {
55 | return "policy/decision_always_true";
56 | } else {
57 | return "policy/decision_always_false";
58 | }
59 | };
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/authorization/BaseAuthorizationEventListenerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import com.styra.opa.OPAClient;
4 | import com.styra.opa.springboot.BaseIntegrationTest;
5 | import com.styra.opa.springboot.OPAAuthorizationManager;
6 | import com.styra.opa.springboot.OPAPathSelector;
7 | import com.styra.opa.springboot.autoconfigure.OPAAutoConfiguration;
8 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
9 | import org.springframework.beans.factory.annotation.Autowired;
10 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
11 | import org.springframework.boot.test.context.SpringBootTest;
12 | import org.springframework.boot.test.context.TestConfiguration;
13 | import org.springframework.context.annotation.Bean;
14 | import org.springframework.context.event.EventListener;
15 | import org.springframework.core.Ordered;
16 | import org.springframework.core.annotation.Order;
17 | import org.springframework.security.authorization.event.AuthorizationDeniedEvent;
18 | import org.springframework.security.authorization.event.AuthorizationGrantedEvent;
19 | import org.springframework.security.config.annotation.web.builders.HttpSecurity;
20 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
21 | import org.springframework.security.core.userdetails.User;
22 | import org.springframework.security.web.SecurityFilterChain;
23 | import org.springframework.stereotype.Component;
24 | import org.springframework.test.web.servlet.MockMvc;
25 | import org.springframework.web.bind.annotation.GetMapping;
26 | import org.springframework.web.bind.annotation.RequestMapping;
27 | import org.springframework.web.bind.annotation.RestController;
28 |
29 | @AutoConfigureMockMvc
30 | @SpringBootTest(classes = {BaseAuthorizationEventListenerTest.CustomOPAConfig.class,
31 | BaseAuthorizationEventListenerTest.CustomAuthorizationEventConfig.class, OPAAutoConfiguration.class})
32 | public class BaseAuthorizationEventListenerTest extends BaseIntegrationTest {
33 |
34 | @Autowired
35 | private CustomAuthorizationEventConfig.CustomAuthorizationEventListener authorizationEventListener;
36 | @Autowired
37 | private MockMvc mockMvc;
38 |
39 | public CustomAuthorizationEventConfig.CustomAuthorizationEventListener getAuthorizationEventListener() {
40 | return authorizationEventListener;
41 | }
42 |
43 | public MockMvc getMockMvc() {
44 | return mockMvc;
45 | }
46 |
47 | @Order(Ordered.HIGHEST_PRECEDENCE + 10)
48 | @EnableWebSecurity
49 | @TestConfiguration
50 | public static class CustomAuthorizationEventConfig {
51 |
52 | @Autowired
53 | private OPAAuthorizationManager opaAuthorizationManager;
54 |
55 | @Component
56 | public static class CustomAuthorizationEventListener {
57 |
58 | private AuthorizationDeniedEvent> lastAuthorizationDeniedEvent;
59 | private AuthorizationGrantedEvent> lastAuthorizationGrantedEvent;
60 |
61 | @EventListener
62 | public void onDeny(AuthorizationDeniedEvent> denied) {
63 | lastAuthorizationDeniedEvent = denied;
64 | }
65 |
66 | @EventListener
67 | public void onGrant(AuthorizationGrantedEvent> granted) {
68 | lastAuthorizationGrantedEvent = granted;
69 | }
70 |
71 | public AuthorizationDeniedEvent> getLastAuthorizationDeniedEvent() {
72 | return lastAuthorizationDeniedEvent;
73 | }
74 |
75 | public AuthorizationGrantedEvent> getLastAuthorizationGrantedEvent() {
76 | return lastAuthorizationGrantedEvent;
77 | }
78 | }
79 |
80 | @RestController
81 | @RequestMapping("/test")
82 | public static class TestController {
83 |
84 | @GetMapping("/hello")
85 | public String sayHello() {
86 | return "Hello world!";
87 | }
88 | }
89 |
90 | @Bean
91 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
92 | return http.authorizeHttpRequests(authorize -> authorize.anyRequest().access(opaAuthorizationManager))
93 | .build();
94 | }
95 | }
96 |
97 | @Order(Ordered.HIGHEST_PRECEDENCE)
98 | @EnableWebSecurity
99 | @TestConfiguration
100 | public static class CustomOPAConfig {
101 | @Bean
102 | public OPAClient opaClient(OPAProperties opaProperties) {
103 | return new OPAClient(opaProperties.getUrl(), HEADERS);
104 | }
105 |
106 | @Bean
107 | public OPAPathSelector opaPathSelector() {
108 | return (authentication, requestAuthorizationContext, opaInput) -> {
109 | if (((User) authentication.getPrincipal()).getUsername().equals("granted_user")) {
110 | return "policy/echo";
111 | } else {
112 | return "policy/decision_always_false";
113 | }
114 | };
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/authorization/DefaultCustomAuthorizationEventListenerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.security.test.context.support.WithMockUser;
5 |
6 | import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
7 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
9 |
10 | public class DefaultCustomAuthorizationEventListenerTest extends BaseAuthorizationEventListenerTest {
11 |
12 | @WithMockUser(username = "denied_user")
13 | @Test
14 | public void testDefaultAuthorizationDeniedEvent() throws Exception {
15 | getMockMvc().perform(get("/test/hello"))
16 | .andExpect(status().isForbidden());
17 |
18 | assertThat(getAuthorizationEventListener().getLastAuthorizationDeniedEvent()).isNotNull();
19 | assertThat(getAuthorizationEventListener().getLastAuthorizationDeniedEvent().getAuthorizationDecision())
20 | .isInstanceOf(OPAAuthorizationDecision.class);
21 | var opaResponse = ((OPAAuthorizationDecision) getAuthorizationEventListener().getLastAuthorizationDeniedEvent()
22 | .getAuthorizationDecision()).getOpaResponse();
23 | assertThat(opaResponse.getDecision()).isEqualTo(false);
24 | }
25 |
26 | @WithMockUser(username = "granted_user")
27 | @Test
28 | public void testDefaultAuthorizationGrantedEvent() throws Exception {
29 | getMockMvc().perform(get("/test/hello"))
30 | .andExpect(status().isOk());
31 |
32 | assertThat(getAuthorizationEventListener().getLastAuthorizationGrantedEvent()).isNull();
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/authorization/ModifiedCustomAuthorizationEventListenerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.security.test.context.support.WithMockUser;
5 | import org.springframework.test.context.TestPropertySource;
6 |
7 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
8 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
9 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
10 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
11 | import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
12 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
14 |
15 | @TestPropertySource(properties = {
16 | "opa.authorization-event.denied.enabled=false",
17 | "opa.authorization-event.granted.enabled=true"
18 | })
19 | public class ModifiedCustomAuthorizationEventListenerTest extends BaseAuthorizationEventListenerTest {
20 |
21 | @WithMockUser(username = "denied_user")
22 | @Test
23 | public void testDisabledAuthorizationDeniedEvent() throws Exception {
24 | getMockMvc().perform(get("/test/hello"))
25 | .andExpect(status().isForbidden());
26 |
27 | assertThat(getAuthorizationEventListener().getLastAuthorizationDeniedEvent()).isNull();
28 | }
29 |
30 | @WithMockUser(username = "granted_user")
31 | @Test
32 | public void testDefaultAuthorizationGrantedEvent() throws Exception {
33 | getMockMvc().perform(get("/test/hello"))
34 | .andExpect(status().isOk());
35 |
36 | assertThat(getAuthorizationEventListener().getLastAuthorizationGrantedEvent()).isNotNull();
37 | assertThat(getAuthorizationEventListener().getLastAuthorizationGrantedEvent().getAuthorizationDecision())
38 | .isInstanceOf(OPAAuthorizationDecision.class);
39 | var opaResponse = ((OPAAuthorizationDecision) getAuthorizationEventListener().getLastAuthorizationGrantedEvent()
40 | .getAuthorizationDecision()).getOpaResponse();
41 | assertThat(opaResponse.getDecision()).isEqualTo(true);
42 | assertThat(opaResponse.getContext()).isNotNull();
43 | assertThat(opaResponse.getContext().getData().get(SUBJECT)).isNotNull();
44 | assertThat(opaResponse.getContext().getData().get(RESOURCE)).isNotNull();
45 | assertThat(opaResponse.getContext().getData().get(ACTION)).isNotNull();
46 | assertThat(opaResponse.getContext().getData().get(CONTEXT)).isNotNull();
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/authorization/OPAAccessDeniedExceptionTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.authorization;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.styra.opa.OPAClient;
5 | import com.styra.opa.springboot.BaseIntegrationTest;
6 | import com.styra.opa.springboot.OPAAuthorizationManager;
7 | import com.styra.opa.springboot.autoconfigure.OPAAutoConfiguration;
8 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
9 | import jakarta.servlet.ServletException;
10 | import jakarta.servlet.http.HttpServletRequest;
11 | import jakarta.servlet.http.HttpServletResponse;
12 | import org.junit.jupiter.api.Test;
13 | import org.springframework.beans.factory.annotation.Autowired;
14 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
15 | import org.springframework.boot.test.context.SpringBootTest;
16 | import org.springframework.boot.test.context.TestConfiguration;
17 | import org.springframework.context.annotation.Bean;
18 | import org.springframework.core.Ordered;
19 | import org.springframework.core.annotation.Order;
20 | import org.springframework.http.HttpStatus;
21 | import org.springframework.http.MediaType;
22 | import org.springframework.security.access.AccessDeniedException;
23 | import org.springframework.security.config.annotation.web.builders.HttpSecurity;
24 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
25 | import org.springframework.security.test.context.support.WithMockUser;
26 | import org.springframework.security.web.SecurityFilterChain;
27 | import org.springframework.security.web.access.AccessDeniedHandlerImpl;
28 | import org.springframework.stereotype.Component;
29 | import org.springframework.test.context.TestPropertySource;
30 | import org.springframework.test.web.servlet.MockMvc;
31 | import org.springframework.web.bind.annotation.GetMapping;
32 | import org.springframework.web.bind.annotation.RequestMapping;
33 | import org.springframework.web.bind.annotation.RestController;
34 |
35 | import java.io.IOException;
36 | import java.nio.charset.StandardCharsets;
37 | import java.util.HashMap;
38 | import java.util.Map;
39 |
40 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
41 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_ID;
42 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
43 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
44 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
45 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
46 |
47 | @TestPropertySource(properties = {"opa.path=policy/decision_always_false"})
48 | @AutoConfigureMockMvc
49 | @SpringBootTest(classes = {OPAAccessDeniedExceptionTest.CustomOPAConfig.class,
50 | OPAAccessDeniedExceptionTest.CustomAuthorizationEventConfig.class, OPAAutoConfiguration.class})
51 | public class OPAAccessDeniedExceptionTest extends BaseIntegrationTest {
52 |
53 | @Autowired
54 | private MockMvc mockMvc;
55 |
56 | @WithMockUser(username = "denied_user")
57 | @Test
58 | public void testDefaultAuthorizationDeniedEvent() throws Exception {
59 | mockMvc.perform(get("/test/hello"))
60 | .andExpect(status().isForbidden())
61 | .andDo(print())
62 | .andExpect(result -> jsonPath("$.title").value("Access Denied"))
63 | .andExpect(result -> jsonPath("$.status").value(HttpStatus.FORBIDDEN.value()))
64 | .andExpect(result -> jsonPath("$.detail").value("Access denied for subject: denied_user"))
65 | .andExpect(result -> jsonPath("$.subject.subject_id").value("denied_user"));
66 | }
67 |
68 | @Order(Ordered.HIGHEST_PRECEDENCE + 10)
69 | @EnableWebSecurity
70 | @TestConfiguration
71 | public static class CustomAuthorizationEventConfig {
72 |
73 | @Autowired
74 | private OPAAuthorizationManager opaAuthorizationManager;
75 |
76 | @Bean
77 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
78 | return http.authorizeHttpRequests(authorize -> authorize.anyRequest().access(opaAuthorizationManager))
79 | .build();
80 | }
81 |
82 | @RestController
83 | @RequestMapping("/test")
84 | public static class TestController {
85 |
86 | @GetMapping("/hello")
87 | public String sayHello() {
88 | return "Hello world!";
89 | }
90 | }
91 |
92 | /**
93 | * Generates JSON error response based on RFC 9457.
94 | * @see RFC 9457 - Problem Details for HTTP APIs
95 | */
96 | @Component
97 | public class ClientOPAAccessDeniedHandler extends AccessDeniedHandlerImpl {
98 |
99 | @Autowired
100 | private ObjectMapper objectMapper;
101 |
102 | @Override
103 | public void handle(HttpServletRequest request, HttpServletResponse response,
104 | AccessDeniedException accessDeniedException) throws IOException, ServletException {
105 | if (!(accessDeniedException instanceof OPAAccessDeniedException opaAccessDeniedException)) {
106 | super.handle(request, response, accessDeniedException);
107 | return;
108 | }
109 | Map body = new HashMap<>();
110 | body.put("status", HttpStatus.FORBIDDEN.value());
111 | body.put("title", opaAccessDeniedException.getMessage());
112 | var subject =
113 | (Map) opaAccessDeniedException.getOpaResponse().getContext().getData().get(SUBJECT);
114 | var subjectId = subject.get(SUBJECT_ID);
115 | body.put("detail", "Access denied for subject: " + subjectId);
116 | body.put("subject", subject);
117 | response.setStatus(HttpStatus.FORBIDDEN.value());
118 | response.setContentType(MediaType.APPLICATION_JSON_VALUE);
119 | response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
120 | response.getWriter().write(objectMapper.writeValueAsString(body));
121 | response.getWriter().flush();
122 | }
123 | }
124 | }
125 |
126 | @Order(Ordered.HIGHEST_PRECEDENCE)
127 | @EnableWebSecurity
128 | @TestConfiguration
129 | public static class CustomOPAConfig {
130 | @Bean
131 | public OPAClient opaClient(OPAProperties opaProperties) {
132 | return new OPAClient(opaProperties.getUrl(), HEADERS);
133 | }
134 |
135 | @Bean
136 | public ObjectMapper objectMapper() {
137 | return new ObjectMapper();
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/autoconfigure/OPAAutoConfigurationTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure;
2 |
3 | import com.styra.opa.OPAClient;
4 | import com.styra.opa.springboot.OPAAuthorizationManager;
5 | import com.styra.opa.springboot.OPAPathSelector;
6 | import org.junit.jupiter.api.Nested;
7 | import org.junit.jupiter.api.Test;
8 | import org.springframework.beans.factory.annotation.Autowired;
9 | import org.springframework.boot.test.context.SpringBootTest;
10 | import org.springframework.context.annotation.Bean;
11 | import org.springframework.context.annotation.Configuration;
12 | import org.springframework.context.annotation.Import;
13 | import org.springframework.test.context.TestPropertySource;
14 |
15 | import java.util.Map;
16 |
17 | import static org.junit.jupiter.api.Assertions.assertEquals;
18 | import static org.junit.jupiter.api.Assertions.assertNotNull;
19 |
20 | @SpringBootTest(classes = OPAAutoConfiguration.class)
21 | public class OPAAutoConfigurationTest {
22 |
23 | @TestPropertySource(properties = {"opa.response.context.reason-key=fr"})
24 | @Nested
25 | public class DefaultOPAAutoConfigurationTest {
26 |
27 | @Autowired(required = false)
28 | private OPAProperties opaProperties;
29 | @Autowired(required = false)
30 | private OPAClient opaClient;
31 | @Autowired(required = false)
32 | private OPAPathSelector opaPathSelector;
33 | @Autowired(required = false)
34 | private OPAAuthorizationManager opaAuthorizationManager;
35 |
36 | @Test
37 | public void testDefaultBeansExistence() {
38 | assertNotNull(opaProperties);
39 | assertNotNull(opaClient);
40 | assertNotNull(opaPathSelector);
41 | assertNotNull(opaAuthorizationManager);
42 | }
43 |
44 | /**
45 | * Make sure that {@link #opaProperties} bean is autowired in {@link #opaAuthorizationManager}.
46 | */
47 | @Test
48 | public void testOPAPropertiesBeanAutowiring() {
49 | assertEquals("fr", opaProperties.getResponse().getContext().getReasonKey());
50 | assertEquals("fr", opaAuthorizationManager.getReasonKey());
51 | }
52 | }
53 |
54 | @Import(OPAAutoConfigurationTestWithCustomOPAClient.CustomOPAClientConfiguration.class)
55 | @Nested
56 | public class OPAAutoConfigurationTestWithCustomOPAClient {
57 |
58 | @Autowired(required = false)
59 | private Map opaClients;
60 |
61 | @Test
62 | public void testCustomOPAClientBeanExistence() {
63 | assertNotNull(opaClients);
64 | assertEquals(1, opaClients.size());
65 | assertNotNull(opaClients.get("customOPAClient"));
66 | }
67 |
68 | @Configuration
69 | public static class CustomOPAClientConfiguration {
70 |
71 | @Bean
72 | public OPAClient customOPAClient() {
73 | return new OPAClient("http://localhost:8182");
74 | }
75 | }
76 | }
77 |
78 | @Import(OPAAutoConfigurationTestWithCustomOPAPathSelector.CustomOPAPathSelectorConfiguration.class)
79 | @Nested
80 | public class OPAAutoConfigurationTestWithCustomOPAPathSelector {
81 |
82 | @Autowired(required = false)
83 | private Map opaPathSelectors;
84 |
85 | @Test
86 | public void testCustomOPAPathSelectorBeanExistence() {
87 | assertNotNull(opaPathSelectors);
88 | assertEquals(1, opaPathSelectors.size());
89 | assertNotNull(opaPathSelectors.get("customOPAPathSelector"));
90 | }
91 |
92 | @Configuration
93 | public static class CustomOPAPathSelectorConfiguration {
94 |
95 | @Bean
96 | public OPAPathSelector customOPAPathSelector() {
97 | return (authentication, requestAuthorizationContext, opaInput) -> "foo/bar";
98 | }
99 | }
100 | }
101 |
102 | @Import(OPAAutoConfigurationTestWithCustomOPAAuthorizationManager.CustomOPAAuthorizationManagerConfiguration.class)
103 | @Nested
104 | public class OPAAutoConfigurationTestWithCustomOPAAuthorizationManager {
105 |
106 | @Autowired(required = false)
107 | private Map opaAuthorizationManagers;
108 |
109 | @Test
110 | public void testCustomOPAAuthorizationMangerBeanExistence() {
111 | assertNotNull(opaAuthorizationManagers);
112 | assertEquals(1, opaAuthorizationManagers.size());
113 | assertNotNull(opaAuthorizationManagers.get("customOPAAuthorizationManager"));
114 | }
115 |
116 | @Configuration
117 | public static class CustomOPAAuthorizationManagerConfiguration {
118 |
119 | @Bean
120 | public OPAAuthorizationManager customOPAAuthorizationManager() {
121 | return new OPAAuthorizationManager("foo/bar2");
122 | }
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/autoconfigure/properties/DefaultOPAPropertiesTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure.properties;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.extension.ExtendWith;
6 | import org.springframework.beans.factory.annotation.Autowired;
7 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
8 | import org.springframework.test.context.junit.jupiter.SpringExtension;
9 |
10 | import static org.junit.jupiter.api.Assertions.assertEquals;
11 | import static org.junit.jupiter.api.Assertions.assertFalse;
12 | import static org.junit.jupiter.api.Assertions.assertNotNull;
13 | import static org.junit.jupiter.api.Assertions.assertNull;
14 | import static org.junit.jupiter.api.Assertions.assertTrue;
15 |
16 | @EnableConfigurationProperties(OPAProperties.class)
17 | @ExtendWith(SpringExtension.class)
18 | public class DefaultOPAPropertiesTest {
19 |
20 | @Autowired
21 | private OPAProperties opaProperties;
22 |
23 | @Test
24 | public void test() {
25 | assertEquals(OPAProperties.DEFAULT_URL, opaProperties.getUrl());
26 | assertNull(opaProperties.getPath());
27 | assertNotNull(opaProperties.getRequest());
28 | assertEquals(OPAProperties.Request.Resource.DEFAULT_TYPE, opaProperties.getRequest().getResource().getType());
29 | assertNotNull(opaProperties.getRequest().getContext());
30 | assertEquals(OPAProperties.Request.Context.DEFAULT_TYPE, opaProperties.getRequest().getContext().getType());
31 | assertEquals(OPAProperties.Request.Subject.DEFAULT_TYPE,
32 | opaProperties.getRequest().getSubject().getType());
33 | assertEquals(OPAProperties.Response.Context.DEFAULT_REASON_KEY,
34 | opaProperties.getResponse().getContext().getReasonKey());
35 | assertTrue(opaProperties.getAuthorizationEvent().getDenied().isEnabled());
36 | assertFalse(opaProperties.getAuthorizationEvent().getGranted().isEnabled());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/autoconfigure/properties/ModifiedOPAPropertiesTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure.properties;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.extension.ExtendWith;
6 | import org.springframework.beans.factory.annotation.Autowired;
7 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
8 | import org.springframework.test.context.TestPropertySource;
9 | import org.springframework.test.context.junit.jupiter.SpringExtension;
10 |
11 | import static org.junit.jupiter.api.Assertions.assertEquals;
12 | import static org.junit.jupiter.api.Assertions.assertFalse;
13 | import static org.junit.jupiter.api.Assertions.assertTrue;
14 |
15 | @TestPropertySource(properties = {
16 | "opa.url=http://localhost:8182",
17 | "opa.path=foo/bar",
18 | "opa.request.resource.type=stomp_endpoint",
19 | "opa.request.context.type=websocket",
20 | "opa.request.subject.type=oauth2_resource_owner",
21 | "opa.response.context.reason-key=de",
22 | "opa.authorization-event.denied.enabled=false",
23 | "opa.authorization-event.granted.enabled=true",
24 | })
25 | @EnableConfigurationProperties(OPAProperties.class)
26 | @ExtendWith(SpringExtension.class)
27 | public class ModifiedOPAPropertiesTest {
28 |
29 | @Autowired
30 | private OPAProperties opaProperties;
31 |
32 | @Test
33 | public void test() {
34 | assertEquals("http://localhost:8182", opaProperties.getUrl());
35 | assertEquals("foo/bar", opaProperties.getPath());
36 | assertEquals("stomp_endpoint", opaProperties.getRequest().getResource().getType());
37 | assertEquals("websocket", opaProperties.getRequest().getContext().getType());
38 | assertEquals("oauth2_resource_owner", opaProperties.getRequest().getSubject().getType());
39 | assertEquals("de", opaProperties.getResponse().getContext().getReasonKey());
40 | assertFalse(opaProperties.getAuthorizationEvent().getDenied().isEnabled());
41 | assertTrue(opaProperties.getAuthorizationEvent().getGranted().isEnabled());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/autoconfigure/properties/ModifiedSystemEnvOPAPropertiesTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.autoconfigure.properties;
2 |
3 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.extension.ExtendWith;
6 | import org.springframework.beans.factory.annotation.Autowired;
7 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
8 | import org.springframework.test.context.junit.jupiter.SpringExtension;
9 |
10 | import static org.junit.jupiter.api.Assertions.assertEquals;
11 |
12 | // Run using: ./gradlew testModifiedSystemEnvProperties
13 | @EnableConfigurationProperties(OPAProperties.class)
14 | @ExtendWith(SpringExtension.class)
15 | public class ModifiedSystemEnvOPAPropertiesTest {
16 |
17 | @Autowired
18 | private OPAProperties opaProperties;
19 |
20 | @Test
21 | public void test() {
22 | assertEquals("http://localhost:8183", opaProperties.getUrl());
23 | assertEquals("foo/bar2", opaProperties.getPath());
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/BaseOpaInputCustomizerIntegrationTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import com.styra.opa.OPAClient;
4 | import com.styra.opa.springboot.BaseIntegrationTest;
5 | import com.styra.opa.springboot.OPAAuthorizationManager;
6 | import com.styra.opa.springboot.OPAPathSelector;
7 | import com.styra.opa.springboot.autoconfigure.OPAProperties;
8 | import org.springframework.beans.factory.annotation.Autowired;
9 | import org.springframework.boot.test.context.TestConfiguration;
10 | import org.springframework.context.annotation.Bean;
11 | import org.springframework.context.annotation.Import;
12 | import org.springframework.core.Ordered;
13 | import org.springframework.core.annotation.Order;
14 |
15 | import java.util.Map;
16 |
17 | import static org.junit.jupiter.api.Assertions.assertNotEquals;
18 | import static org.junit.jupiter.api.Assertions.assertNotNull;
19 | import static org.mockito.Mockito.when;
20 |
21 | @Import(BaseOpaInputCustomizerIntegrationTest.CustomOPAInputCustomizerConfig.class)
22 | public class BaseOpaInputCustomizerIntegrationTest extends BaseIntegrationTest {
23 | @Autowired
24 | private OPAAuthorizationManager opaAuthorizationManager;
25 |
26 | protected Map callAuthorizationManagerAndVerify() {
27 | var expectedResponseContextData = createNullMockAuthOPAInput();
28 | var mockAuth = createNullMockAuthentication();
29 | when(authenticationSupplier.get()).thenReturn(mockAuth);
30 |
31 | var actualResponse = getOpaAuthorizationManager().opaRequest(authenticationSupplier, context);
32 |
33 | assertNotNull(actualResponse.getContext());
34 | assertNotNull(actualResponse.getContext().getData());
35 | var actualResponseContextData = actualResponse.getContext().getData();
36 | assertNotEquals(expectedResponseContextData, actualResponseContextData);
37 | return actualResponseContextData;
38 | }
39 |
40 | protected OPAAuthorizationManager getOpaAuthorizationManager() {
41 | return opaAuthorizationManager;
42 | }
43 |
44 | @Order(Ordered.HIGHEST_PRECEDENCE)
45 | @TestConfiguration
46 | public static class CustomOPAInputCustomizerConfig {
47 | @Bean
48 | public OPAClient opaClient(OPAProperties opaProperties) {
49 | return new OPAClient(opaProperties.getUrl(), HEADERS);
50 | }
51 |
52 | @Bean
53 | public OPAPathSelector opaPathSelector() {
54 | return (authentication, requestAuthorizationContext, opaInput) -> "policy/echo";
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/OPAInputActionCustomizerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.boot.test.context.TestConfiguration;
5 | import org.springframework.context.annotation.Bean;
6 | import org.springframework.context.annotation.Import;
7 | import org.springframework.core.Ordered;
8 | import org.springframework.core.annotation.Order;
9 |
10 | import java.util.HashMap;
11 | import java.util.Map;
12 |
13 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
14 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
15 | import static org.junit.jupiter.api.Assertions.assertEquals;
16 | import static org.junit.jupiter.api.Assertions.assertNotNull;
17 |
18 | @Import(OPAInputActionCustomizerTest.OPAInputActionCustomizerConfig.class)
19 | public class OPAInputActionCustomizerTest extends BaseOpaInputCustomizerIntegrationTest {
20 |
21 | @Test
22 | @SuppressWarnings("unchecked")
23 | public void testOPAInputActionCustomizer() {
24 | var actualResponseContextData = callAuthorizationManagerAndVerify();
25 | assertNotNull(actualResponseContextData.get(ACTION));
26 | var actualAction = (Map) actualResponseContextData.get(ACTION);
27 | assertEquals("read", actualAction.get(ACTION_NAME));
28 | assertEquals("action_value", actualAction.get("action_key"));
29 | }
30 |
31 | @Order(Ordered.HIGHEST_PRECEDENCE)
32 | @TestConfiguration
33 | public static class OPAInputActionCustomizerConfig {
34 | @Bean
35 | public OPAInputActionCustomizer opaInputActionCustomizer() {
36 | return (authentication, requestAuthorizationContext, action) -> {
37 | var customAction = new HashMap<>(action);
38 | customAction.put(ACTION_NAME, "read");
39 | customAction.put("action_key", "action_value");
40 | return customAction;
41 | };
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/OPAInputContextCustomizerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.junit.jupiter.api.Nested;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.boot.test.context.TestConfiguration;
6 | import org.springframework.context.annotation.Bean;
7 | import org.springframework.context.annotation.Import;
8 | import org.springframework.core.Ordered;
9 | import org.springframework.core.annotation.Order;
10 |
11 | import java.util.HashMap;
12 | import java.util.Map;
13 |
14 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
15 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
16 | import static org.junit.jupiter.api.Assertions.assertEquals;
17 | import static org.junit.jupiter.api.Assertions.assertNotNull;
18 | import static org.junit.jupiter.api.Assertions.assertNull;
19 |
20 | public class OPAInputContextCustomizerTest {
21 |
22 | @Nested
23 | @Import(NullOPAInputContextCustomizerTest.NullOPAInputContextCustomizerConfig.class)
24 | class NullOPAInputContextCustomizerTest extends BaseOpaInputCustomizerIntegrationTest {
25 | @Test
26 | public void testNullOPAInputContextCustomizer() {
27 | var actualResponseContextData = callAuthorizationManagerAndVerify();
28 | assertNull(actualResponseContextData.get(CONTEXT));
29 | }
30 |
31 | @Order(Ordered.HIGHEST_PRECEDENCE)
32 | @TestConfiguration
33 | public static class NullOPAInputContextCustomizerConfig {
34 | @Bean
35 | public OPAInputContextCustomizer opaInputContextCustomizer() {
36 | return (authentication, requestAuthorizationContext, context) -> null;
37 | }
38 | }
39 | }
40 |
41 | @Nested
42 | @Import(NotNullOPAInputContextCustomizerTest.NotNullOPAInputContextCustomizerConfig.class)
43 | class NotNullOPAInputContextCustomizerTest extends BaseOpaInputCustomizerIntegrationTest {
44 |
45 | @Test
46 | @SuppressWarnings("unchecked")
47 | public void testNotNullOPAInputContextCustomizer() {
48 | var actualResponseContextData = callAuthorizationManagerAndVerify();
49 | assertNotNull(actualResponseContextData.get(CONTEXT));
50 | var actualContext = (Map) actualResponseContextData.get(CONTEXT);
51 | assertEquals("websocket", actualContext.get(CONTEXT_TYPE));
52 | assertEquals("context_value", actualContext.get("context_key"));
53 | }
54 |
55 | @Order(Ordered.HIGHEST_PRECEDENCE)
56 | @TestConfiguration
57 | public static class NotNullOPAInputContextCustomizerConfig {
58 | @Bean
59 | public OPAInputContextCustomizer opaInputContextCustomizer() {
60 | return (authentication, requestAuthorizationContext, context) -> {
61 | var customContext = new HashMap<>(context);
62 | customContext.put(CONTEXT_TYPE, "websocket");
63 | customContext.put("context_key", "context_value");
64 | return customContext;
65 | };
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/OPAInputResourceCustomizerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.boot.test.context.TestConfiguration;
5 | import org.springframework.context.annotation.Bean;
6 | import org.springframework.context.annotation.Import;
7 | import org.springframework.core.Ordered;
8 | import org.springframework.core.annotation.Order;
9 |
10 | import java.util.HashMap;
11 | import java.util.Map;
12 |
13 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
14 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
15 | import static org.junit.jupiter.api.Assertions.assertEquals;
16 | import static org.junit.jupiter.api.Assertions.assertNotNull;
17 |
18 | @Import(OPAInputResourceCustomizerTest.OPAInputResourceCustomizerConfig.class)
19 | public class OPAInputResourceCustomizerTest extends BaseOpaInputCustomizerIntegrationTest {
20 |
21 | @Test
22 | @SuppressWarnings("unchecked")
23 | public void testOPAInputResourceCustomizer() {
24 | var actualResponseContextData = callAuthorizationManagerAndVerify();
25 | assertNotNull(actualResponseContextData.get(RESOURCE));
26 | var actualResource = (Map) actualResponseContextData.get(RESOURCE);
27 | assertEquals("stomp_endpoint", actualResource.get(RESOURCE_TYPE));
28 | assertEquals("resource_value", actualResource.get("resource_key"));
29 | }
30 |
31 | @Order(Ordered.HIGHEST_PRECEDENCE)
32 | @TestConfiguration
33 | public static class OPAInputResourceCustomizerConfig {
34 | @Bean
35 | public OPAInputResourceCustomizer opaInputResourceCustomizer() {
36 | return (authentication, requestAuthorizationContext, resource) -> {
37 | var customResource = new HashMap<>(resource);
38 | customResource.put(RESOURCE_TYPE, "stomp_endpoint");
39 | customResource.put("resource_key", "resource_value");
40 | return customResource;
41 | };
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/OPAInputSubjectCustomizerTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.boot.test.context.TestConfiguration;
5 | import org.springframework.context.annotation.Bean;
6 | import org.springframework.context.annotation.Import;
7 | import org.springframework.core.Ordered;
8 | import org.springframework.core.annotation.Order;
9 |
10 | import java.util.HashMap;
11 | import java.util.Map;
12 |
13 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
14 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
15 | import static org.junit.jupiter.api.Assertions.assertEquals;
16 | import static org.junit.jupiter.api.Assertions.assertNotNull;
17 |
18 | @Import(OPAInputSubjectCustomizerTest.OPAInputSubjectCustomizerConfig.class)
19 | public class OPAInputSubjectCustomizerTest extends BaseOpaInputCustomizerIntegrationTest {
20 |
21 | @Test
22 | @SuppressWarnings("unchecked")
23 | public void testOPAInputSubjectCustomizer() {
24 | Map actualResponseContextData = callAuthorizationManagerAndVerify();
25 | assertNotNull(actualResponseContextData.get(SUBJECT));
26 | Map actualSubject = (Map) actualResponseContextData.get(SUBJECT);
27 | assertEquals("oauth2_resource_owner", actualSubject.get(SUBJECT_TYPE));
28 | assertEquals("subject_value", actualSubject.get("subject_key"));
29 | }
30 |
31 | @Order(Ordered.HIGHEST_PRECEDENCE)
32 | @TestConfiguration
33 | public static class OPAInputSubjectCustomizerConfig {
34 | @Bean
35 | public OPAInputSubjectCustomizer opaInputSubjectCustomizer() {
36 | return (authentication, requestAuthorizationContext, subject) -> {
37 | var customSubject = new HashMap<>(subject);
38 | customSubject.put(SUBJECT_TYPE, "oauth2_resource_owner");
39 | customSubject.put("subject_key", "subject_value");
40 | return customSubject;
41 | };
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/test/java/com/styra/opa/springboot/input/OPAInputValidatorTest.java:
--------------------------------------------------------------------------------
1 | package com.styra.opa.springboot.input;
2 |
3 | import org.junit.jupiter.api.Nested;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.boot.test.context.TestConfiguration;
6 | import org.springframework.context.annotation.Bean;
7 | import org.springframework.context.annotation.Import;
8 | import org.springframework.core.Ordered;
9 | import org.springframework.core.annotation.Order;
10 | import org.springframework.security.access.AccessDeniedException;
11 |
12 | import java.util.HashMap;
13 |
14 | import static com.styra.opa.springboot.input.InputConstants.ACTION;
15 | import static com.styra.opa.springboot.input.InputConstants.ACTION_NAME;
16 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT;
17 | import static com.styra.opa.springboot.input.InputConstants.CONTEXT_TYPE;
18 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE;
19 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_ID;
20 | import static com.styra.opa.springboot.input.InputConstants.RESOURCE_TYPE;
21 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT;
22 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_ID;
23 | import static com.styra.opa.springboot.input.InputConstants.SUBJECT_TYPE;
24 | import static com.styra.opa.springboot.input.OPAInputValidator.EXCEPTION_MESSAGE_TEMPLATE;
25 | import static java.lang.String.format;
26 | import static org.junit.jupiter.api.Assertions.assertEquals;
27 | import static org.junit.jupiter.api.Assertions.assertThrows;
28 |
29 | public class OPAInputValidatorTest {
30 |
31 | @Nested
32 | @Import(OPAInputSubjectTypeValidationTest.OPAInputSubjectCustomizerConfig.class)
33 | class OPAInputSubjectTypeValidationTest extends BaseOpaInputCustomizerIntegrationTest {
34 | @Test
35 | public void testSubjectTypeValidation() {
36 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
37 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, SUBJECT, SUBJECT_TYPE), exception.getMessage());
38 | }
39 |
40 | @Order(Ordered.HIGHEST_PRECEDENCE)
41 | @TestConfiguration
42 | public static class OPAInputSubjectCustomizerConfig {
43 | @Bean
44 | public OPAInputSubjectCustomizer opaInputSubjectCustomizer() {
45 | return (authentication, requestAuthorizationContext, subject) -> {
46 | var customSubject = new HashMap<>(subject);
47 | customSubject.remove(SUBJECT_TYPE);
48 | return customSubject;
49 | };
50 | }
51 | }
52 | }
53 |
54 | @Nested
55 | @Import(OPAInputSubjectIdValidationTest.OPAInputSubjectCustomizerConfig.class)
56 | class OPAInputSubjectIdValidationTest extends BaseOpaInputCustomizerIntegrationTest {
57 | @Test
58 | public void testSubjectIdValidation() {
59 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
60 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, SUBJECT, SUBJECT_ID), exception.getMessage());
61 | }
62 |
63 | @Order(Ordered.HIGHEST_PRECEDENCE)
64 | @TestConfiguration
65 | public static class OPAInputSubjectCustomizerConfig {
66 | @Bean
67 | public OPAInputSubjectCustomizer opaInputSubjectCustomizer() {
68 | return (authentication, requestAuthorizationContext, subject) -> {
69 | var customSubject = new HashMap<>(subject);
70 | customSubject.remove(SUBJECT_ID);
71 | return customSubject;
72 | };
73 | }
74 | }
75 | }
76 |
77 | @Nested
78 | @Import(OPAInputResourceTypeValidationTest.OPAInputResourceCustomizerConfig.class)
79 | class OPAInputResourceTypeValidationTest extends BaseOpaInputCustomizerIntegrationTest {
80 | @Test
81 | public void testResourceTypeValidation() {
82 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
83 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, RESOURCE, RESOURCE_TYPE), exception.getMessage());
84 | }
85 |
86 | @Order(Ordered.HIGHEST_PRECEDENCE)
87 | @TestConfiguration
88 | public static class OPAInputResourceCustomizerConfig {
89 | @Bean
90 | public OPAInputResourceCustomizer opaInputResourceCustomizer() {
91 | return (authentication, requestAuthorizationContext, resource) -> {
92 | var customResource = new HashMap<>(resource);
93 | customResource.remove(RESOURCE_TYPE);
94 | return customResource;
95 | };
96 | }
97 | }
98 | }
99 |
100 | @Nested
101 | @Import(OPAInputResourceIdValidationTest.OPAInputResourceCustomizerConfig.class)
102 | class OPAInputResourceIdValidationTest extends BaseOpaInputCustomizerIntegrationTest {
103 | @Test
104 | public void testResourceTypeValidation() {
105 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
106 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, RESOURCE, RESOURCE_ID), exception.getMessage());
107 | }
108 |
109 | @Order(Ordered.HIGHEST_PRECEDENCE)
110 | @TestConfiguration
111 | public static class OPAInputResourceCustomizerConfig {
112 | @Bean
113 | public OPAInputResourceCustomizer opaInputResourceCustomizer() {
114 | return (authentication, requestAuthorizationContext, resource) -> {
115 | var customResource = new HashMap<>(resource);
116 | customResource.remove(RESOURCE_ID);
117 | return customResource;
118 | };
119 | }
120 | }
121 | }
122 |
123 | @Nested
124 | @Import(OPAInputActionNameValidationTest.OPAInputActionCustomizerConfig.class)
125 | class OPAInputActionNameValidationTest extends BaseOpaInputCustomizerIntegrationTest {
126 | @Test
127 | public void testActionNameValidation() {
128 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
129 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, ACTION, ACTION_NAME), exception.getMessage());
130 | }
131 |
132 | @Order(Ordered.HIGHEST_PRECEDENCE)
133 | @TestConfiguration
134 | public static class OPAInputActionCustomizerConfig {
135 | @Bean
136 | public OPAInputActionCustomizer opaInputActionCustomizer() {
137 | return (authentication, requestAuthorizationContext, action) -> {
138 | var customAction = new HashMap<>(action);
139 | customAction.remove(ACTION_NAME);
140 | return customAction;
141 | };
142 | }
143 | }
144 | }
145 |
146 | @Nested
147 | @Import(OPAInputContextTypeValidationTest.OPAInputContextCustomizerConfig.class)
148 | class OPAInputContextTypeValidationTest extends BaseOpaInputCustomizerIntegrationTest {
149 | @Test
150 | public void testContextTypeValidation() {
151 | var exception = assertThrows(AccessDeniedException.class, this::callAuthorizationManagerAndVerify);
152 | assertEquals(format(EXCEPTION_MESSAGE_TEMPLATE, CONTEXT, CONTEXT_TYPE), exception.getMessage());
153 | }
154 |
155 | @Order(Ordered.HIGHEST_PRECEDENCE)
156 | @TestConfiguration
157 | public static class OPAInputContextCustomizerConfig {
158 | @Bean
159 | public OPAInputContextCustomizer opaInputContextCustomizer() {
160 | return (authentication, requestAuthorizationContext, context) -> {
161 | var customContext = new HashMap<>(context);
162 | customContext.remove(CONTEXT_TYPE);
163 | return customContext;
164 | };
165 | }
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/test/resources/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 | set -u
5 | set -x
6 |
7 | # We want to run nginx attached to the same foreground session as OPA, but
8 | # without blocking OPA. We can do this simply using Bash job control.
9 | nginx -g "daemon off;" &
10 |
11 | opa $@
12 |
--------------------------------------------------------------------------------
/src/test/resources/nginx.conf:
--------------------------------------------------------------------------------
1 | # This nginx config creates an additional :8282 endpoint that prefixes the
2 | # OPA API with /customprefix/. This is used to test that the OPA Java SDK
3 | # plays nicely with OPA hosted behind reverse proxy setups.
4 |
5 | events {}
6 |
7 | http {
8 | server {
9 | listen 8282;
10 |
11 | location /customprefix/ {
12 | proxy_pass http://localhost:8181/;
13 | proxy_set_header Host $host;
14 | proxy_set_header X-Real-IP $remote_addr;
15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
16 | proxy_set_header X-Forwarded-Proto $scheme;
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/resources/opa.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest
2 |
3 | RUN apk add nginx bash
4 |
5 | ADD nginx.conf /etc/nginx/nginx.conf
6 |
7 | ADD entrypoint.sh /entrypoint.sh
8 |
9 | RUN chmod +x /entrypoint.sh
10 |
11 | COPY --from=openpolicyagent/opa:latest-static /opa /usr/bin/opa
12 |
13 | ENTRYPOINT ["/entrypoint.sh"]
14 |
15 |
--------------------------------------------------------------------------------
/testdata/simple/auth.rego:
--------------------------------------------------------------------------------
1 | package system.authz
2 |
3 | default allow := false
4 |
5 | allow if {
6 | input.identity == ["secret", "supersecret", "superdupersecret"][_]
7 | }
8 |
9 | allow if {
10 | input.path == ["health"]
11 | }
12 |
--------------------------------------------------------------------------------
/testdata/simple/policy.rego:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | echo := {
4 | "decision": true,
5 | "context": {
6 | "id": "0",
7 | "reason_user": {
8 | "en": "echo rule always allows",
9 | "other": "other reason key",
10 | },
11 | "data": input,
12 | }
13 | }
14 |
15 | always_false := false
16 |
17 | always_true := true
18 |
19 | decision_always_false := {"decision": false}
20 |
21 | decision_always_true := {"decision": true}
22 |
--------------------------------------------------------------------------------
/testdata/simple/system.rego:
--------------------------------------------------------------------------------
1 | package system
2 |
3 | # This is used to exercise the default query functionality.
4 |
5 | msg := "this is the default path"
6 |
7 | main := x if {
8 | x := {"msg": msg, "echo": input}
9 | } else if {
10 | x := {"msg": msg}
11 | }
12 |
--------------------------------------------------------------------------------