mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 07:28:21 +00:00
Merge remote-tracking branch 'origin/main' into feat/ENG-3443
This commit is contained in:
+2
-13
@@ -31,25 +31,14 @@ SMTP_FROM_NAME=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# Integration
|
||||
# Optional only if integration is used
|
||||
CLIENT_ID_HEROKU=
|
||||
CLIENT_ID_VERCEL=
|
||||
CLIENT_ID_NETLIFY=
|
||||
# CICD Integration
|
||||
CLIENT_ID_GITHUB=
|
||||
CLIENT_ID_GITHUB_APP=
|
||||
CLIENT_SLUG_GITHUB_APP=
|
||||
CLIENT_ID_GITLAB=
|
||||
CLIENT_ID_BITBUCKET=
|
||||
CLIENT_SECRET_HEROKU=
|
||||
CLIENT_SECRET_VERCEL=
|
||||
CLIENT_SECRET_NETLIFY=
|
||||
CLIENT_SECRET_GITHUB=
|
||||
CLIENT_SECRET_GITHUB_APP=
|
||||
CLIENT_ID_GITLAB=
|
||||
CLIENT_SECRET_GITLAB=
|
||||
CLIENT_SECRET_BITBUCKET=
|
||||
CLIENT_SLUG_VERCEL=
|
||||
|
||||
CLIENT_PRIVATE_KEY_GITHUB_APP=
|
||||
CLIENT_APP_ID_GITHUB_APP=
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# Description 📣
|
||||
## Context
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Here's how we expect a pull request to be : https://infisical.com/docs/contributing/getting-started/pull-requests -->
|
||||
<!-- What problem does this solve? What was the behavior before, and what is it now? Add all relevant context. Link related issues/tickets. -->
|
||||
|
||||
## Type ✨
|
||||
## Screenshots
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
<!-- If UI/UX changes, add screenshots or videos. Delete if not applicable. -->
|
||||
|
||||
## Steps to verify the change
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] Fix
|
||||
- [ ] Feature
|
||||
- [ ] Improvement
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation
|
||||
- [ ] Breaking
|
||||
- [ ] Docs
|
||||
- [ ] Chore
|
||||
|
||||
# Tests 🛠️
|
||||
## Checklist
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. You may want to add screenshots when relevant and possible -->
|
||||
|
||||
```sh
|
||||
# Here's some code block to paste some code snippets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview), agreed and acknowledged the [code of conduct](https://infisical.com/docs/contributing/getting-started/code-of-conduct). 📝
|
||||
- [ ] Title follows the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format: `type(scope): short description` (scope is optional, e.g., `fix: prevent crash on sync` or `fix(api): handle null response`).
|
||||
- [ ] Tested locally
|
||||
- [ ] Updated docs (if needed)
|
||||
- [ ] Read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview)
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Validate PR Title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
validate-pr-title:
|
||||
name: Validate PR Title Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR Title Format
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title;
|
||||
|
||||
// Valid PR types based on pull_request_template.md
|
||||
const validTypes = ['fix', 'feature', 'improvement', 'breaking', 'docs', 'chore'];
|
||||
|
||||
// Regex pattern: type(optional-scope): short description
|
||||
// - Type must be one of the valid types
|
||||
// - Scope is optional, must be in parentheses, lowercase alphanumeric with hyphens
|
||||
// - Followed by colon, space, and description (must start with lowercase letter)
|
||||
const pattern = new RegExp(`^(${validTypes.join('|')})(\\([a-z0-9-]+\\))?: [a-z].+$`);
|
||||
|
||||
if (!pattern.test(title)) {
|
||||
const errorMessage = `
|
||||
❌ **Invalid PR Title Format**
|
||||
|
||||
Your PR title: \`${title}\`
|
||||
|
||||
**Expected format:** \`type(scope): short description\` (description must start with lowercase)
|
||||
|
||||
**Valid types:**
|
||||
- \`fix\` - Bug fixes
|
||||
- \`feature\` - New features
|
||||
- \`improvement\` - Enhancements to existing features
|
||||
- \`breaking\` - Breaking changes
|
||||
- \`docs\` - Documentation updates
|
||||
- \`chore\` - Maintenance tasks
|
||||
|
||||
**Scope:** Optional, short identifier in parentheses (e.g., \`(api)\`, \`(auth)\`, \`(ui)\`)
|
||||
|
||||
**Examples:**
|
||||
- \`fix: prevent crash on sync\`
|
||||
- \`fix(api): handle null response from auth endpoint\`
|
||||
- \`docs(cli): update installation guide\`
|
||||
`;
|
||||
|
||||
core.setFailed(errorMessage);
|
||||
} else {
|
||||
console.log(`✅ PR title is valid: "${title}"`);
|
||||
}
|
||||
|
||||
@@ -74,3 +74,4 @@ cli/test/infisical-merge
|
||||
backend/bdd/.bdd-infisical-bootstrap-result.json
|
||||
|
||||
/npm/bin
|
||||
__pycache__
|
||||
|
||||
@@ -55,3 +55,7 @@ docs/integrations/app-connections/redis.mdx:generic-api-key:80
|
||||
backend/src/ee/services/app-connections/chef/chef-connection-fns.ts:private-key:42
|
||||
docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
|
||||
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
|
||||
docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62
|
||||
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
|
||||
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246
|
||||
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248
|
||||
@@ -185,6 +185,9 @@ COPY --from=backend-runner /app /backend
|
||||
|
||||
COPY --from=frontend-runner /app ./backend/frontend-build
|
||||
|
||||
# Make export-assets script executable for CDN asset extraction
|
||||
RUN chmod +x /backend/scripts/export-assets.sh
|
||||
|
||||
ARG INFISICAL_PLATFORM_VERSION
|
||||
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ ENV VITE_POSTHOG_API_KEY $POSTHOG_API_KEY
|
||||
ARG INTERCOM_ID
|
||||
ENV VITE_INTERCOM_ID $INTERCOM_ID
|
||||
ARG INFISICAL_PLATFORM_VERSION
|
||||
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
|
||||
ENV VITE_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
|
||||
ARG CAPTCHA_SITE_KEY
|
||||
ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY
|
||||
@@ -173,6 +174,9 @@ ENV CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY
|
||||
COPY --from=backend-runner /app /backend
|
||||
COPY --from=frontend-runner /app ./backend/frontend-build
|
||||
|
||||
# Make export-assets script executable for CDN asset extraction
|
||||
RUN chmod +x /backend/scripts/export-assets.sh
|
||||
|
||||
ARG INFISICAL_PLATFORM_VERSION
|
||||
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
|
||||
import pathlib
|
||||
import typing
|
||||
from copy import deepcopy
|
||||
|
||||
import httpx
|
||||
from behave.runner import Context
|
||||
@@ -86,14 +87,13 @@ def bootstrap_infisical(context: Context):
|
||||
|
||||
ca_slug = faker.slug()
|
||||
resp = client.post(
|
||||
"/api/v1/pki/ca/internal",
|
||||
"/api/v1/cert-manager/ca/internal",
|
||||
headers=headers,
|
||||
json={
|
||||
"projectId": project["id"],
|
||||
"name": ca_slug,
|
||||
"type": "internal",
|
||||
"status": "active",
|
||||
"enableDirectIssuance": True,
|
||||
"configuration": {
|
||||
"type": "root",
|
||||
"organization": "Infisican Inc",
|
||||
@@ -114,7 +114,7 @@ def bootstrap_infisical(context: Context):
|
||||
|
||||
cert_template_slug = faker.slug()
|
||||
resp = client.post(
|
||||
"/api/v2/certificate-templates",
|
||||
"/api/v1/cert-manager/certificate-templates",
|
||||
headers=headers,
|
||||
json={
|
||||
"projectId": project["id"],
|
||||
@@ -185,28 +185,33 @@ def bootstrap_infisical(context: Context):
|
||||
|
||||
|
||||
def before_all(context: Context):
|
||||
base_vars = {
|
||||
"BASE_URL": BASE_URL,
|
||||
"PEBBLE_URL": PEBBLE_URL,
|
||||
}
|
||||
if BOOTSTRAP_INFISICAL:
|
||||
details = bootstrap_infisical(context)
|
||||
context.vars = {
|
||||
"BASE_URL": BASE_URL,
|
||||
"PEBBLE_URL": PEBBLE_URL,
|
||||
vars = base_vars | {
|
||||
"PROJECT_ID": details["project"]["id"],
|
||||
"CERT_CA_ID": details["ca"]["id"],
|
||||
"CERT_TEMPLATE_ID": details["cert_template"]["id"],
|
||||
"AUTH_TOKEN": details["auth_token"],
|
||||
}
|
||||
else:
|
||||
context.vars = {
|
||||
"BASE_URL": BASE_URL,
|
||||
"PEBBLE_URL": PEBBLE_URL,
|
||||
vars = base_vars | {
|
||||
"PROJECT_ID": PROJECT_ID,
|
||||
"CERT_CA_ID": CERT_CA_ID,
|
||||
"CERT_TEMPLATE_ID": CERT_TEMPLATE_ID,
|
||||
"AUTH_TOKEN": AUTH_TOKEN,
|
||||
}
|
||||
context._initial_vars = vars
|
||||
context.http_client = httpx.Client(base_url=BASE_URL)
|
||||
|
||||
|
||||
def before_scenario(context: Context, scenario: typing.Any):
|
||||
context.vars = deepcopy(context._initial_vars)
|
||||
|
||||
|
||||
def after_scenario(context: Context, scenario: typing.Any):
|
||||
if hasattr(context, "web_server"):
|
||||
context.web_server.shutdown_and_server_close()
|
||||
|
||||
@@ -2,7 +2,7 @@ Feature: Access Control
|
||||
|
||||
Scenario Outline: Access resources across different account
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0
|
||||
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -34,7 +34,7 @@ Feature: Access Control
|
||||
Then the value response.status_code should not be equal to 404
|
||||
And I put away current ACME client as client0
|
||||
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email maidu@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account1
|
||||
Then I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "<url>"
|
||||
@@ -53,7 +53,7 @@ Feature: Access Control
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url | payload |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {order.uri} | |
|
||||
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
|
||||
| order | . | not_used | {order.uri}/certificate | |
|
||||
@@ -62,7 +62,7 @@ Feature: Access Control
|
||||
|
||||
Scenario Outline: Access resources across a different profiles
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0
|
||||
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -96,7 +96,7 @@ Feature: Access Control
|
||||
|
||||
Given I make a random slug as profile_slug
|
||||
Given I use AUTH_TOKEN for authentication
|
||||
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
|
||||
When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload
|
||||
"""
|
||||
{
|
||||
"projectId": "{PROJECT_ID}",
|
||||
@@ -110,10 +110,10 @@ Feature: Access Control
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
Then I memorize response with jq ".certificateProfile.id" as profile_id
|
||||
When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
When I send a "GET" request to "/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
Then I memorize response with jq ".eabKid" as eab_kid
|
||||
And I memorize response with jq ".eabSecret" as eab_secret
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{profile_id}/directory"
|
||||
Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1
|
||||
Then I peak and memorize the next nonce as nonce
|
||||
Then I memorize <src_var> with jq "<jq>" as <dest_var>
|
||||
@@ -133,7 +133,7 @@ Feature: Access Control
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url | payload |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {order.uri} | |
|
||||
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
|
||||
| order | . | not_used | {order.uri}/certificate | |
|
||||
@@ -143,7 +143,7 @@ Feature: Access Control
|
||||
|
||||
Scenario Outline: Access resources across a different profile with the same key pair
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0
|
||||
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -177,7 +177,7 @@ Feature: Access Control
|
||||
|
||||
Given I make a random slug as profile_slug
|
||||
Given I use AUTH_TOKEN for authentication
|
||||
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
|
||||
When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload
|
||||
"""
|
||||
{
|
||||
"projectId": "{PROJECT_ID}",
|
||||
@@ -191,10 +191,10 @@ Feature: Access Control
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
Then I memorize response with jq ".certificateProfile.id" as profile_id
|
||||
When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
When I send a "GET" request to "/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
Then I memorize response with jq ".eabKid" as eab_kid
|
||||
And I memorize response with jq ".eabSecret" as eab_secret
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" with the key pair from client0
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{profile_id}/directory" with the key pair from client0
|
||||
Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1
|
||||
Then I peak and memorize the next nonce as nonce
|
||||
Then I memorize <src_var> with jq "<jq>" as <dest_var>
|
||||
@@ -214,17 +214,16 @@ Feature: Access Control
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url | payload |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
|
||||
| order | . | not_used | {order.uri} | |
|
||||
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
|
||||
| order | . | not_used | {order.uri}/certificate | |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} | |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} |
|
||||
|
||||
|
||||
Scenario Outline: URL mismatch
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
Then I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -259,8 +258,8 @@ Feature: Access Control
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | actual_url | bad_url | error_detail |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | URL mismatch in the protected header |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | URL mismatch in the protected header |
|
||||
| order | . | not_used | {order.uri} | BAD | Invalid URL in the protected header |
|
||||
| order | . | not_used | {order.uri} | https://example.com/acmes/orders/FOOBAR | URL mismatch in the protected header |
|
||||
| order | . | not_used | {order.uri}/finalize | BAD | Invalid URL in the protected header |
|
||||
@@ -271,3 +270,52 @@ Feature: Access Control
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} | https://example.com/acmes/auths/FOOBAR | URL mismatch in the protected header |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | BAD | Invalid URL in the protected header |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | https://example.com/acmes/challenges/FOOBAR | URL mismatch in the protected header |
|
||||
|
||||
Scenario Outline: Send KID and JWK in the same time
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
"""
|
||||
Then I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I peak and memorize the next nonce as nonce_value
|
||||
And I memorize <src_var> with jq "<jq>" as <dest_var>
|
||||
When I send a raw ACME request to "<url>"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce_value}",
|
||||
"url": "<url>",
|
||||
"kid": "{acme_account.uri}",
|
||||
"jwk": {
|
||||
"n": "mmEWxUv2lUYDZe_M2FXJ_WDXgHoEG7PVvg-dfz1STzyMwx0qvM66KMenXSyVA0r-_Ssb6p8VexSWGOFKskM4ryKUihn2KNH5e8nXZBqzqYeKQ8vqaCdaWzTxFI1dg0xhk0CWptkZHxpRpLalztFJ1Pq7L2qvQOM2YT7wPYbwQhpaSiVNXAb1W4FwAPyC04v1mHehvST-esaDT7j_5-eU5cCcmyi4_g5nBawcinOjj5o3VCg4X8UjK--AjhAyYHx1nRMr-7xk4x-0VIpQ_OODjLB3WzN8s1YEb0Jx5Bv1JyeCw35zahqs3fAFyRje-p5ENk9NCxfz5x9ZGkszkkNt0Q",
|
||||
"e": "AQAB",
|
||||
"kty": "RSA"
|
||||
}
|
||||
},
|
||||
"payload": {}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 400
|
||||
And the value response with jq ".status" should be equal to 400
|
||||
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:malformed"
|
||||
And the value response with jq ".detail" should be equal to "Both JWK and KID are provided in the protected header"
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order |
|
||||
| order | . | not_used | {order.uri} |
|
||||
| order | . | not_used | {order.uri}/finalize |
|
||||
| order | . | not_used | {order.uri}/certificate |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
|
||||
|
||||
@@ -2,27 +2,46 @@ Feature: Account
|
||||
|
||||
Scenario: Create a new account
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/(.+)
|
||||
And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/(.+)
|
||||
|
||||
Scenario: Create a new account with the same key pair twice
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri as kid
|
||||
And I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account2
|
||||
And the value error.__class__.__name__ should be equal to "ConflictError"
|
||||
And the value error.location should be equal to "{kid}"
|
||||
|
||||
Scenario: Find an existing account
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri as account_uri
|
||||
And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And the value acme_account.uri should be equal to "{account_uri}"
|
||||
And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as retrieved_account
|
||||
And the value retrieved_account.uri should be equal to "{account_uri}"
|
||||
|
||||
# Note: This is a very special case for cert-manager.
|
||||
Scenario: Create a new account with EAB then retrieve it without EAB
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri as account_uri
|
||||
And I find the existing ACME account without EAB as retrieved_account
|
||||
And the value error with should be absent
|
||||
And the value retrieved_account.uri should be equal to "{account_uri}"
|
||||
|
||||
Scenario: Create a new account without EAB
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com without EAB
|
||||
And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired"
|
||||
|
||||
Scenario Outline: Scenario: Create a new account with bad EAB credentials
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "<eab_kid>" with secret "<eab_secret>" as acme_account
|
||||
And the value error with jq ".type" should be equal to "<error_type>"
|
||||
And the value error with jq ".detail" should be equal to "<error_msg>"
|
||||
@@ -38,17 +57,17 @@ Feature: Account
|
||||
|
||||
Scenario Outline: Scenario: Create a new account with bad EAB url
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
And I use a different new-account URL "<url>" for EAB signature
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired"
|
||||
And the value error with jq ".detail" should be equal to "External account binding URL mismatch"
|
||||
|
||||
Examples: Bad URLs
|
||||
| url |
|
||||
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad |
|
||||
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account?foo=bar |
|
||||
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account#foobar |
|
||||
| {BASE_URL}/acme/new-account |
|
||||
| https://example.com/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad |
|
||||
| bad |
|
||||
| url |
|
||||
| {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account-bad |
|
||||
| {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account?foo=bar |
|
||||
| {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account#foobar |
|
||||
| {BASE_URL}/acme/new-account |
|
||||
| https://example.com/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account-bad |
|
||||
| bad |
|
||||
|
||||
@@ -2,7 +2,7 @@ Feature: Authorization
|
||||
|
||||
Scenario: Get authorization
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -14,7 +14,7 @@ Feature: Authorization
|
||||
Then I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+)
|
||||
And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/authorizations/(.+)
|
||||
And the value order.authorizations[0].body with jq ".status" should be equal to "pending"
|
||||
And the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ Feature: ACME Cert Profile
|
||||
Scenario: Create a cert profile
|
||||
Given I make a random slug as profile_slug
|
||||
And I use AUTH_TOKEN for authentication
|
||||
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
|
||||
When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload
|
||||
"""
|
||||
{
|
||||
"projectId": "{PROJECT_ID}",
|
||||
@@ -25,7 +25,7 @@ Feature: ACME Cert Profile
|
||||
Scenario: Reveal EAB secret
|
||||
Given I make a random slug as profile_slug
|
||||
And I use AUTH_TOKEN for authentication
|
||||
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
|
||||
When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload
|
||||
"""
|
||||
{
|
||||
"projectId": "{PROJECT_ID}",
|
||||
@@ -39,11 +39,11 @@ Feature: ACME Cert Profile
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And I memorize response with jq ".certificateProfile.id" as profile_id
|
||||
When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
When I send a "GET" request to "/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".eabKid" should be equal to "{profile_id}"
|
||||
And the value response with jq ".eabSecret" should be present
|
||||
And I memorize response with jq ".eabKid" as eab_kid
|
||||
And I memorize response with jq ".eabSecret" as eab_secret
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{profile_id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account
|
||||
|
||||
@@ -2,7 +2,7 @@ Feature: Challenge
|
||||
|
||||
Scenario: Validate challenge
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -22,9 +22,31 @@ Feature: Challenge
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq ".subject.common_name" should be equal to "localhost"
|
||||
|
||||
Scenario: Validate challenge with retry
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
"""
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I select challenge with type http-01 for domain localhost from order in order as challenge
|
||||
And I wait 45 seconds and serve challenge response for challenge at localhost
|
||||
And I tell ACME server that challenge is ready to be verified
|
||||
And I poll and finalize the ACME order order as finalized_order
|
||||
And the value finalized_order.body with jq ".status" should be equal to "valid"
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq ".subject.common_name" should be equal to "localhost"
|
||||
|
||||
Scenario: Validate challenges for multiple domains
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -58,18 +80,17 @@ Feature: Challenge
|
||||
|
||||
Scenario: Did not finish all challenges
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
{}
|
||||
"""
|
||||
And I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
[
|
||||
"localhost",
|
||||
"infisical.com"
|
||||
]
|
||||
"""
|
||||
@@ -82,56 +103,19 @@ Feature: Challenge
|
||||
|
||||
# the localhost auth should be valid
|
||||
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "localhost")) | first | .uri" as localhost_auth
|
||||
And I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "{localhost_auth}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{localhost_auth}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "valid"
|
||||
And I wait until the status of authorization localhost_auth becomes valid
|
||||
|
||||
# the infisical.com auth should still be pending
|
||||
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "infisical.com")) | first | .uri" as infisical_auth
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
When I send a raw ACME request to "{infisical_auth}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{infisical_auth}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "pending"
|
||||
And I post-as-get {infisical_auth} as infisical_auth_resp
|
||||
And the value infisical_auth_resp with jq ".status" should be equal to "pending"
|
||||
|
||||
# the order should be pending as well
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
When I send a raw ACME request to "{order.uri}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{order.uri}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "pending"
|
||||
And I post-as-get {order.uri} as order_resp
|
||||
And the value order_resp with jq ".status" should be equal to "pending"
|
||||
|
||||
# finalize should not be allowed when all auths are not valid yet
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
And I get a new-nonce as nonce
|
||||
When I send a raw ACME request to "{order.body.finalize}"
|
||||
"""
|
||||
{
|
||||
@@ -153,7 +137,7 @@ Feature: Challenge
|
||||
|
||||
Scenario: CSR names mismatch with order identifier
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -165,13 +149,13 @@ Feature: Challenge
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
Then I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
|
||||
"url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order",
|
||||
"kid": "{acme_account.uri}"
|
||||
},
|
||||
"payload": {
|
||||
@@ -185,8 +169,10 @@ Feature: Challenge
|
||||
Then the value response.status_code should be equal to 201
|
||||
And I memorize response with jq ".finalize" as finalize_url
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
And I memorize response.headers with jq ".["location"]" as order_uri
|
||||
And I memorize response as order
|
||||
And I pass all challenges with type http-01 for order in order
|
||||
And I wait until the status of order order_uri becomes ready
|
||||
And I encode CSR csr_pem as JOSE Base-64 DER as base64_csr_der
|
||||
When I send a raw ACME request to "{finalize_url}"
|
||||
"""
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
Feature: Directory
|
||||
|
||||
Scenario: Get the directory of ACME service urls
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I send a "GET" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
Then the response status code should be "200"
|
||||
And the response body should match JSON value
|
||||
"""
|
||||
{
|
||||
"newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce",
|
||||
"newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account",
|
||||
"newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,17 @@
|
||||
Feature: Directory
|
||||
|
||||
Scenario: Get the directory of ACME service urls
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I send a "GET" request to "/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then the response status code should be "200"
|
||||
And the response body should match JSON value
|
||||
"""
|
||||
{
|
||||
"newNonce": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-nonce",
|
||||
"newAccount": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account",
|
||||
"newOrder": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order",
|
||||
"meta": {
|
||||
"externalAccountRequired": true
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -1,6 +1,7 @@
|
||||
Feature: External CA
|
||||
|
||||
Scenario: Issue a certificate from an external CA
|
||||
@cloudflare
|
||||
Scenario Outline: Issue a certificate from an external CA with Cloudflare
|
||||
Given I create a Cloudflare connection as cloudflare
|
||||
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
|
||||
Given I create a external ACME CA with the following config as ext_ca
|
||||
@@ -87,14 +88,12 @@ Feature: External CA
|
||||
"""
|
||||
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
|
||||
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
<subject>
|
||||
"""
|
||||
# Pebble has a strict rule to only takes SANs
|
||||
Then I add subject alternative name to certificate signing request csr
|
||||
@@ -178,3 +177,541 @@ Feature: External CA
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
|
||||
Examples:
|
||||
| subject |
|
||||
| {"COMMON_NAME": "localhost"} |
|
||||
| {} |
|
||||
|
||||
@dnsme
|
||||
Scenario Outline: Issue a certificate from an external CA with DNS Made Easy
|
||||
Given I create a DNS Made Easy connection as dnsme
|
||||
Then I memorize dnsme with jq ".appConnection.id" as app_conn_id
|
||||
Given I create a external ACME CA with the following config as ext_ca
|
||||
"""
|
||||
{
|
||||
"dnsProviderConfig": {
|
||||
"provider": "dns-made-easy",
|
||||
"hostedZoneId": "MOCK_ZONE_ID"
|
||||
},
|
||||
"directoryUrl": "{PEBBLE_URL}",
|
||||
"accountEmail": "fangpen@infisical.com",
|
||||
"dnsAppConnectionId": "{app_conn_id}",
|
||||
"eabKid": "",
|
||||
"eabHmacKey": ""
|
||||
}
|
||||
"""
|
||||
Then I memorize ext_ca with jq ".id" as ext_ca_id
|
||||
Given I create a certificate template with the following config as cert_template
|
||||
"""
|
||||
{
|
||||
"subject": [
|
||||
{
|
||||
"type": "common_name",
|
||||
"allowed": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sans": [
|
||||
{
|
||||
"type": "dns_name",
|
||||
"allowed": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"digital_signature",
|
||||
"key_encipherment",
|
||||
"non_repudiation",
|
||||
"data_encipherment",
|
||||
"key_agreement",
|
||||
"key_cert_sign",
|
||||
"crl_sign",
|
||||
"encipher_only",
|
||||
"decipher_only"
|
||||
]
|
||||
},
|
||||
"extendedKeyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"client_auth",
|
||||
"server_auth",
|
||||
"code_signing",
|
||||
"email_protection",
|
||||
"ocsp_signing",
|
||||
"time_stamping"
|
||||
]
|
||||
},
|
||||
"algorithms": {
|
||||
"signature": [
|
||||
"SHA256-RSA",
|
||||
"SHA512-RSA",
|
||||
"SHA384-ECDSA",
|
||||
"SHA384-RSA",
|
||||
"SHA256-ECDSA",
|
||||
"SHA512-ECDSA"
|
||||
],
|
||||
"keyAlgorithm": [
|
||||
"RSA-2048",
|
||||
"RSA-4096",
|
||||
"ECDSA-P384",
|
||||
"RSA-3072",
|
||||
"ECDSA-P256",
|
||||
"ECDSA-P521"
|
||||
]
|
||||
},
|
||||
"validity": {
|
||||
"max": "365d"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
|
||||
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
<subject>
|
||||
"""
|
||||
# Pebble has a strict rule to only takes SANs
|
||||
Then I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
[
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I select challenge with type http-01 for domain localhost from order in order as challenge
|
||||
And I serve challenge response for challenge at localhost
|
||||
And I tell ACME server that challenge is ready to be verified
|
||||
Given I intercept outgoing requests
|
||||
"""
|
||||
[
|
||||
{
|
||||
"scope": "https://api.dnsmadeeasy.com:443",
|
||||
"method": "POST",
|
||||
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records",
|
||||
"status": 201,
|
||||
"response": {
|
||||
"gtdLocation": "DEFAULT",
|
||||
"failed": false,
|
||||
"monitor": false,
|
||||
"failover": false,
|
||||
"sourceId": 895364,
|
||||
"dynamicDns": false,
|
||||
"hardLink": false,
|
||||
"ttl": 60,
|
||||
"source": 1,
|
||||
"name": "_acme-challenge",
|
||||
"value": "\"MOCK_HTTP_01_VALUE\"",
|
||||
"id": 12345678,
|
||||
"type": "TXT"
|
||||
},
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://api.dnsmadeeasy.com:443",
|
||||
"method": "GET",
|
||||
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records?type=TXT&recordName=_acme-challenge&page=0",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"totalRecords": 1,
|
||||
"totalPages": 1,
|
||||
"data": [
|
||||
{
|
||||
"gtdLocation": "DEFAULT",
|
||||
"failed": false,
|
||||
"monitor": false,
|
||||
"failover": false,
|
||||
"sourceId": 895364,
|
||||
"dynamicDns": false,
|
||||
"hardLink": false,
|
||||
"ttl": 60,
|
||||
"source": 1,
|
||||
"name": "_acme-challenge",
|
||||
"value": "\"MOCK_CHALLENGE_VALUE\"",
|
||||
"id": 1111111,
|
||||
"type": "TXT"
|
||||
}
|
||||
],
|
||||
"page": 0
|
||||
},
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://api.dnsmadeeasy.com:443",
|
||||
"method": "DELETE",
|
||||
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records/1111111",
|
||||
"status": 200,
|
||||
"response": "",
|
||||
"responseIsBinary": false
|
||||
}
|
||||
]
|
||||
"""
|
||||
Then I poll and finalize the ACME order order as finalized_order
|
||||
And the value finalized_order.body with jq ".status" should be equal to "valid"
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
|
||||
"""
|
||||
[
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
|
||||
Examples:
|
||||
| subject |
|
||||
| {"COMMON_NAME": "localhost"} |
|
||||
| {} |
|
||||
|
||||
Scenario Outline: Issue a certificate with bad CSR names disallowed by the template
|
||||
Given I create a Cloudflare connection as cloudflare
|
||||
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
|
||||
Given I create a external ACME CA with the following config as ext_ca
|
||||
"""
|
||||
{
|
||||
"dnsProviderConfig": {
|
||||
"provider": "cloudflare",
|
||||
"hostedZoneId": "MOCK_ZONE_ID"
|
||||
},
|
||||
"directoryUrl": "{PEBBLE_URL}",
|
||||
"accountEmail": "fangpen@infisical.com",
|
||||
"dnsAppConnectionId": "{app_conn_id}",
|
||||
"eabKid": "",
|
||||
"eabHmacKey": ""
|
||||
}
|
||||
"""
|
||||
Then I memorize ext_ca with jq ".id" as ext_ca_id
|
||||
Given I create a certificate template with the following config as cert_template
|
||||
"""
|
||||
{
|
||||
"subject": [
|
||||
{
|
||||
"type": "common_name",
|
||||
"allowed": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sans": [
|
||||
{
|
||||
"type": "dns_name",
|
||||
"allowed": [
|
||||
"infisical.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"digital_signature",
|
||||
"key_encipherment",
|
||||
"non_repudiation",
|
||||
"data_encipherment",
|
||||
"key_agreement",
|
||||
"key_cert_sign",
|
||||
"crl_sign",
|
||||
"encipher_only",
|
||||
"decipher_only"
|
||||
]
|
||||
},
|
||||
"extendedKeyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"client_auth",
|
||||
"server_auth",
|
||||
"code_signing",
|
||||
"email_protection",
|
||||
"ocsp_signing",
|
||||
"time_stamping"
|
||||
]
|
||||
},
|
||||
"algorithms": {
|
||||
"signature": [
|
||||
"SHA256-RSA",
|
||||
"SHA512-RSA",
|
||||
"SHA384-ECDSA",
|
||||
"SHA384-RSA",
|
||||
"SHA256-ECDSA",
|
||||
"SHA512-ECDSA"
|
||||
],
|
||||
"keyAlgorithm": [
|
||||
"RSA-2048",
|
||||
"RSA-4096",
|
||||
"ECDSA-P384",
|
||||
"RSA-3072",
|
||||
"ECDSA-P256",
|
||||
"ECDSA-P521"
|
||||
]
|
||||
},
|
||||
"validity": {
|
||||
"max": "365d"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
|
||||
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
<subject>
|
||||
"""
|
||||
Then I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
<san>
|
||||
"""
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I pass all challenges with type http-01 for order in order
|
||||
Given I intercept outgoing requests
|
||||
"""
|
||||
[
|
||||
{
|
||||
"scope": "https://api.cloudflare.com:443",
|
||||
"method": "POST",
|
||||
"path": "/client/v4/zones/MOCK_ZONE_ID/dns_records",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"result": {
|
||||
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
|
||||
"name": "Mock Account",
|
||||
"type": "standard",
|
||||
"settings": {
|
||||
"enforce_twofactor": false,
|
||||
"api_access_enabled": null,
|
||||
"access_approval_expiry": null,
|
||||
"abuse_contact_email": null,
|
||||
"user_groups_ui_beta": false
|
||||
},
|
||||
"legacy_flags": {
|
||||
"enterprise_zone_quota": {
|
||||
"maximum": 0,
|
||||
"current": 0,
|
||||
"available": 0
|
||||
}
|
||||
},
|
||||
"created_on": "2013-04-18T00:41:02.215243Z"
|
||||
},
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": []
|
||||
},
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://api.cloudflare.com:443",
|
||||
"method": "GET",
|
||||
"path": {
|
||||
"regex": "/client/v4/zones/[^/]+/dns_records\\?"
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"result": [],
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": [],
|
||||
"result_info": {
|
||||
"page": 1,
|
||||
"per_page": 100,
|
||||
"count": 0,
|
||||
"total_count": 0,
|
||||
"total_pages": 1
|
||||
}
|
||||
},
|
||||
"responseIsBinary": false
|
||||
}
|
||||
]
|
||||
"""
|
||||
Then I poll and finalize the ACME order order as finalized_order
|
||||
And the value error.typ should be equal to "urn:ietf:params:acme:error:badCSR"
|
||||
And the value error.detail should be equal to "<err_detail>"
|
||||
|
||||
Examples:
|
||||
| subject | san | err_detail |
|
||||
| {"COMMON_NAME": "localhost"} | [] | Invalid CSR: common_name value 'localhost' is not in allowed values list |
|
||||
| {"COMMON_NAME": "localhost"} | ["infisical.com"] | Invalid CSR: common_name value 'localhost' is not in allowed values list |
|
||||
| {} | ["localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
|
||||
| {} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
|
||||
| {"COMMON_NAME": "example.com"} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
|
||||
|
||||
|
||||
Scenario Outline: Issue a certificate with algorithms disallowed by the template
|
||||
Given I create a Cloudflare connection as cloudflare
|
||||
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
|
||||
Given I create a external ACME CA with the following config as ext_ca
|
||||
"""
|
||||
{
|
||||
"dnsProviderConfig": {
|
||||
"provider": "cloudflare",
|
||||
"hostedZoneId": "MOCK_ZONE_ID"
|
||||
},
|
||||
"directoryUrl": "{PEBBLE_URL}",
|
||||
"accountEmail": "fangpen@infisical.com",
|
||||
"dnsAppConnectionId": "{app_conn_id}",
|
||||
"eabKid": "",
|
||||
"eabHmacKey": ""
|
||||
}
|
||||
"""
|
||||
Then I memorize ext_ca with jq ".id" as ext_ca_id
|
||||
Given I create a certificate template with the following config as cert_template
|
||||
"""
|
||||
{
|
||||
"subject": [
|
||||
{
|
||||
"type": "common_name",
|
||||
"allowed": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sans": [
|
||||
{
|
||||
"type": "dns_name",
|
||||
"allowed": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"digital_signature",
|
||||
"key_encipherment",
|
||||
"non_repudiation",
|
||||
"data_encipherment",
|
||||
"key_agreement",
|
||||
"key_cert_sign",
|
||||
"crl_sign",
|
||||
"encipher_only",
|
||||
"decipher_only"
|
||||
]
|
||||
},
|
||||
"extendedKeyUsages": {
|
||||
"required": [],
|
||||
"allowed": [
|
||||
"client_auth",
|
||||
"server_auth",
|
||||
"code_signing",
|
||||
"email_protection",
|
||||
"ocsp_signing",
|
||||
"time_stamping"
|
||||
]
|
||||
},
|
||||
"algorithms": {
|
||||
"signature": [
|
||||
"<allowed_signature>"
|
||||
],
|
||||
"keyAlgorithm": [
|
||||
"<allowed_alg>"
|
||||
]
|
||||
},
|
||||
"validity": {
|
||||
"max": "365d"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
|
||||
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
[
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
And I create a <key_type> private key pair as cert_key
|
||||
And I sign the certificate signing request csr with "<hash_type>" hash and private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I pass all challenges with type http-01 for order in order
|
||||
Given I intercept outgoing requests
|
||||
"""
|
||||
[
|
||||
{
|
||||
"scope": "https://api.cloudflare.com:443",
|
||||
"method": "POST",
|
||||
"path": "/client/v4/zones/MOCK_ZONE_ID/dns_records",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"result": {
|
||||
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
|
||||
"name": "Mock Account",
|
||||
"type": "standard",
|
||||
"settings": {
|
||||
"enforce_twofactor": false,
|
||||
"api_access_enabled": null,
|
||||
"access_approval_expiry": null,
|
||||
"abuse_contact_email": null,
|
||||
"user_groups_ui_beta": false
|
||||
},
|
||||
"legacy_flags": {
|
||||
"enterprise_zone_quota": {
|
||||
"maximum": 0,
|
||||
"current": 0,
|
||||
"available": 0
|
||||
}
|
||||
},
|
||||
"created_on": "2013-04-18T00:41:02.215243Z"
|
||||
},
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": []
|
||||
},
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://api.cloudflare.com:443",
|
||||
"method": "GET",
|
||||
"path": {
|
||||
"regex": "/client/v4/zones/[^/]+/dns_records\\?"
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"result": [],
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": [],
|
||||
"result_info": {
|
||||
"page": 1,
|
||||
"per_page": 100,
|
||||
"count": 0,
|
||||
"total_count": 0,
|
||||
"total_pages": 1
|
||||
}
|
||||
},
|
||||
"responseIsBinary": false
|
||||
}
|
||||
]
|
||||
"""
|
||||
Then I poll and finalize the ACME order order as finalized_order
|
||||
And the value error.typ should be equal to "urn:ietf:params:acme:error:badCSR"
|
||||
And the value error.detail should be equal to "<err_detail>"
|
||||
|
||||
Examples:
|
||||
| allowed_alg | allowed_signature | key_type | hash_type | err_detail |
|
||||
| RSA-4096 | SHA512-RSA | RSA-2048 | SHA512 | Invalid CSR: Key algorithm 'RSA_2048' is not allowed by template policy |
|
||||
| RSA-4096 | SHA512-RSA | RSA-3072 | SHA512 | Invalid CSR: Key algorithm 'RSA_3072' is not allowed by template policy |
|
||||
| RSA-4096 | ECDSA-SHA512 | ECDSA-P256 | SHA512 | Invalid CSR: Key algorithm 'EC_prime256v1' is not allowed by template policy |
|
||||
| RSA-4096 | ECDSA-SHA512 | ECDSA-P384 | SHA512 | Invalid CSR: Key algorithm 'EC_secp384r1' is not allowed by template policy |
|
||||
| RSA-4096 | ECDSA-SHA512 | ECDSA-P521 | SHA512 | Invalid CSR: Key algorithm 'EC_secp521r1' is not allowed by template policy |
|
||||
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA384 | Invalid CSR: Signature algorithm 'RSA-SHA384' is not allowed by template policy |
|
||||
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy |
|
||||
| ECDSA-P256 | SHA512-RSA | ECDSA-P256 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
|
||||
| ECDSA-P384 | SHA512-RSA | ECDSA-P384 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
|
||||
| ECDSA-P521 | SHA512-RSA | ECDSA-P521 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
|
||||
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy |
|
||||
| RSA-2048 | SHA512-RSA | RSA-4096 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy, Key algorithm 'RSA_4096' is not allowed by template policy |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Feature: Internal CA
|
||||
|
||||
Scenario: CSR with SANs only
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
And I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
[
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I select challenge with type http-01 for domain localhost from order in order as challenge
|
||||
And I serve challenge response for challenge at localhost
|
||||
And I tell ACME server that challenge is ready to be verified
|
||||
And I poll and finalize the ACME order order as finalized_order
|
||||
And the value finalized_order.body with jq ".status" should be equal to "valid"
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq ".subject.common_name" should be equal to null
|
||||
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
|
||||
"""
|
||||
[
|
||||
"localhost"
|
||||
]
|
||||
"""
|
||||
@@ -2,13 +2,13 @@ Feature: Nonce
|
||||
|
||||
Scenario: Generate a new nonce
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I send a "HEAD" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce"
|
||||
When I send a "HEAD" request to "/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-nonce"
|
||||
Then the response status code should be "200"
|
||||
And the response header "Replay-Nonce" should contains non-empty value
|
||||
|
||||
Scenario Outline: Send a bad nonce to account endpoints
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -40,18 +40,18 @@ Feature: Nonce
|
||||
And the value response with jq ".detail" should be equal to "Invalid nonce"
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order |
|
||||
| order | . | not_used | {order.uri} |
|
||||
| order | . | not_used | {order.uri}/finalize |
|
||||
| order | . | not_used | {order.uri}/certificate |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
|
||||
| src_var | jq | dest_var | url |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order |
|
||||
| order | . | not_used | {order.uri} |
|
||||
| order | . | not_used | {order.uri}/finalize |
|
||||
| order | . | not_used | {order.uri}/certificate |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
|
||||
|
||||
Scenario Outline: Send the same nonce twice
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
|
||||
When I create certificate signing request as csr
|
||||
@@ -65,13 +65,13 @@ Feature: Nonce
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I peak and memorize the next nonce as nonce_value
|
||||
When I send a raw ACME request to "/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders"
|
||||
When I send a raw ACME request to "/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce_value}",
|
||||
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders",
|
||||
"url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders",
|
||||
"kid": "{acme_account.uri}"
|
||||
},
|
||||
"payload": {}
|
||||
@@ -97,11 +97,11 @@ Feature: Nonce
|
||||
And the value response with jq ".detail" should be equal to "Invalid nonce"
|
||||
|
||||
Examples: Endpoints
|
||||
| src_var | jq | dest_var | url |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order |
|
||||
| order | . | not_used | {order.uri} |
|
||||
| order | . | not_used | {order.uri}/finalize |
|
||||
| order | . | not_used | {order.uri}/certificate |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
|
||||
| src_var | jq | dest_var | url |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
|
||||
| order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order |
|
||||
| order | . | not_used | {order.uri} |
|
||||
| order | . | not_used | {order.uri}/finalize |
|
||||
| order | . | not_used | {order.uri}/certificate |
|
||||
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
|
||||
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
|
||||
|
||||
@@ -2,7 +2,7 @@ Feature: Order
|
||||
|
||||
Scenario: Create a new order
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -14,15 +14,15 @@ Feature: Order
|
||||
Then I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)
|
||||
And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/orders/(.+)
|
||||
And the value order.body with jq ".status" should be equal to "pending"
|
||||
And the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
|
||||
And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
|
||||
And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
|
||||
And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
|
||||
And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
|
||||
|
||||
Scenario: Create a new order with SANs
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -52,7 +52,7 @@ Feature: Order
|
||||
|
||||
Scenario: Fetch an order
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
@@ -67,21 +67,21 @@ Feature: Order
|
||||
And I send an ACME post-as-get to order.uri as fetched_order
|
||||
And the value fetched_order with jq ".status" should be equal to "pending"
|
||||
And the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
|
||||
And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
|
||||
And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
|
||||
And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
|
||||
And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
|
||||
|
||||
Scenario Outline: Create an order with invalid identifier types
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
|
||||
"url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order",
|
||||
"kid": "{acme_account.uri}"
|
||||
},
|
||||
"payload": {
|
||||
@@ -105,16 +105,16 @@ Feature: Order
|
||||
|
||||
Scenario Outline: Create an order with invalid identifier values
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
And I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
|
||||
When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
|
||||
"url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order",
|
||||
"kid": "{acme_account.uri}"
|
||||
},
|
||||
"payload": {
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
import time
|
||||
import threading
|
||||
|
||||
import acme.client
|
||||
import jq
|
||||
@@ -18,6 +20,10 @@ from josepy.jwk import JWKRSA
|
||||
from josepy import json_util
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.types import (
|
||||
CertificateIssuerPrivateKeyTypes,
|
||||
)
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
@@ -56,7 +62,7 @@ def step_impl(context: Context, profile_var: str):
|
||||
profile_slug = faker.slug()
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/pki/certificate-profiles",
|
||||
"/api/v1/cert-manager/certificate-profiles",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"projectId": context.vars["PROJECT_ID"],
|
||||
@@ -74,7 +80,7 @@ def step_impl(context: Context, profile_var: str):
|
||||
kid = profile_id
|
||||
|
||||
response = context.http_client.get(
|
||||
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -147,13 +153,47 @@ def step_impl(context: Context, var_name: str):
|
||||
context.vars[var_name] = response
|
||||
|
||||
|
||||
@given("I create a DNS Made Easy connection as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
conn_slug = faker.slug()
|
||||
with with_nocks(
|
||||
context,
|
||||
definitions=[
|
||||
{
|
||||
"scope": "https://api.dnsmadeeasy.com:443",
|
||||
"method": "GET",
|
||||
"path": "/V2.0/dns/managed/",
|
||||
"status": 200,
|
||||
"response": {"totalRecords": 0, "totalPages": 1, "data": [], "page": 0},
|
||||
"responseIsBinary": False,
|
||||
}
|
||||
],
|
||||
):
|
||||
response = context.http_client.post(
|
||||
"/api/v1/app-connections/dns-made-easy",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"name": conn_slug,
|
||||
"description": "",
|
||||
"method": "api-key-secret",
|
||||
"credentials": {
|
||||
"apiKey": "MOCK_API_KEY",
|
||||
"secretKey": "MOCK_SECRET_KEY",
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
context.vars[var_name] = response
|
||||
|
||||
|
||||
@given("I create a external ACME CA with the following config as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
ca_slug = faker.slug()
|
||||
config = replace_vars(json.loads(context.text), context.vars)
|
||||
response = context.http_client.post(
|
||||
"/api/v1/pki/ca/acme",
|
||||
"/api/v1/cert-manager/ca/acme",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"projectId": context.vars["PROJECT_ID"],
|
||||
@@ -174,7 +214,7 @@ def step_impl(context: Context, var_name: str):
|
||||
template_slug = faker.slug()
|
||||
config = replace_vars(json.loads(context.text), context.vars)
|
||||
response = context.http_client.post(
|
||||
"/api/v2/certificate-templates",
|
||||
"/api/v1/cert-manager/certificate-templates",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"projectId": context.vars["PROJECT_ID"],
|
||||
@@ -194,7 +234,7 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str):
|
||||
profile_slug = faker.slug()
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/pki/certificate-profiles",
|
||||
"/api/v1/cert-manager/certificate-profiles",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"projectId": context.vars["PROJECT_ID"],
|
||||
@@ -212,7 +252,7 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str):
|
||||
kid = profile_id
|
||||
|
||||
response = context.http_client.get(
|
||||
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -236,7 +276,7 @@ def step_impl(context: Context, profile_var: str):
|
||||
profile_slug = faker.slug()
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/pki/certificate-profiles",
|
||||
"/api/v1/cert-manager/certificate-profiles",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json={
|
||||
"projectId": context.vars["PROJECT_ID"],
|
||||
@@ -254,7 +294,7 @@ def step_impl(context: Context, profile_var: str):
|
||||
kid = profile_id
|
||||
|
||||
response = context.http_client.get(
|
||||
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -387,6 +427,9 @@ def register_account_with_eab(
|
||||
):
|
||||
acme_client = context.acme_client
|
||||
account_public_key = acme_client.net.key.public_key()
|
||||
if not only_return_existing:
|
||||
# clear the account in case if we want to register twice
|
||||
acme_client.net.account = None
|
||||
if hasattr(context, "alt_eab_url"):
|
||||
eab_directory = messages.Directory.from_json(
|
||||
{"newAccount": context.alt_eab_url}
|
||||
@@ -406,8 +449,14 @@ def register_account_with_eab(
|
||||
only_return_existing=only_return_existing,
|
||||
)
|
||||
try:
|
||||
context.vars[account_var] = acme_client.new_account(registration)
|
||||
if not only_return_existing:
|
||||
context.vars[account_var] = acme_client.new_account(registration)
|
||||
else:
|
||||
context.vars[account_var] = acme_client.query_registration(
|
||||
acme_client.net.account
|
||||
)
|
||||
except Exception as exp:
|
||||
logger.error(f"Failed to register: {exp}", exc_info=True)
|
||||
context.vars["error"] = exp
|
||||
|
||||
|
||||
@@ -434,6 +483,17 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var:
|
||||
)
|
||||
|
||||
|
||||
@then("I find the existing ACME account without EAB as {account_var}")
|
||||
def step_impl(context: Context, account_var: str):
|
||||
acme_client = context.acme_client
|
||||
# registration = messages.RegistrationResource.from_json(dict(uri=""))
|
||||
registration = acme_client.net.account
|
||||
try:
|
||||
context.vars[account_var] = acme_client.query_registration(registration)
|
||||
except Exception as exp:
|
||||
context.vars["error"] = exp
|
||||
|
||||
|
||||
@then("I register a new ACME account with email {email} without EAB")
|
||||
def step_impl(context: Context, email: str):
|
||||
acme_client = context.acme_client
|
||||
@@ -541,12 +601,57 @@ def step_impl(context: Context, csr_var: str):
|
||||
)
|
||||
|
||||
|
||||
@then("I create a RSA private key pair as {rsa_key_var}")
|
||||
def step_impl(context: Context, rsa_key_var: str):
|
||||
context.vars[rsa_key_var] = rsa.generate_private_key(
|
||||
# TODO: make them configurable if we need to
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
def gen_private_key(key_type: str):
|
||||
if key_type == "RSA-2048" or key_type == "RSA":
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
elif key_type == "RSA-3072":
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=3072,
|
||||
)
|
||||
elif key_type == "RSA-4096":
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=4096,
|
||||
)
|
||||
elif key_type == "ECDSA-P256":
|
||||
return ec.generate_private_key(curve=ec.SECP256R1())
|
||||
elif key_type == "ECDSA-P384":
|
||||
return ec.generate_private_key(curve=ec.SECP384R1())
|
||||
elif key_type == "ECDSA-P521":
|
||||
return ec.generate_private_key(curve=ec.SECP521R1())
|
||||
else:
|
||||
raise Exception(f"Unknown key type {key_type}")
|
||||
|
||||
|
||||
@then("I create a {key_type} private key pair as {rsa_key_var}")
|
||||
def step_impl(context: Context, key_type: str, rsa_key_var: str):
|
||||
context.vars[rsa_key_var] = gen_private_key(key_type)
|
||||
|
||||
|
||||
def sign_csr(
|
||||
pem: x509.CertificateSigningRequestBuilder,
|
||||
pk: CertificateIssuerPrivateKeyTypes,
|
||||
hash_type: str = "SHA256",
|
||||
):
|
||||
return pem.sign(pk, getattr(hashes, hash_type)()).public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'I sign the certificate signing request {csr_var} with "{hash_type}" hash and private key {pk_var} and output it as {pem_var} in PEM format'
|
||||
)
|
||||
def step_impl(
|
||||
context: Context, csr_var: str, hash_type: str, pk_var: str, pem_var: str
|
||||
):
|
||||
context.vars[pem_var] = sign_csr(
|
||||
pem=context.vars[csr_var],
|
||||
pk=context.vars[pk_var],
|
||||
hash_type=hash_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -554,10 +659,9 @@ def step_impl(context: Context, rsa_key_var: str):
|
||||
"I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format"
|
||||
)
|
||||
def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str):
|
||||
context.vars[pem_var] = (
|
||||
context.vars[csr_var]
|
||||
.sign(context.vars[pk_var], hashes.SHA256())
|
||||
.public_bytes(serialization.Encoding.PEM)
|
||||
context.vars[pem_var] = sign_csr(
|
||||
pem=context.vars[csr_var],
|
||||
pk=context.vars[pk_var],
|
||||
)
|
||||
|
||||
|
||||
@@ -600,6 +704,19 @@ def step_impl(context: Context, var_path: str, jq_query: str):
|
||||
)
|
||||
|
||||
|
||||
@then("the value {var_path} with should be absent")
|
||||
def step_impl(context: Context, var_path: str):
|
||||
try:
|
||||
value = eval_var(context, var_path)
|
||||
except Exception as exp:
|
||||
if isinstance(exp, KeyError):
|
||||
return
|
||||
raise
|
||||
assert False, (
|
||||
f"value at {var_path!r} should be absent, but we got this instead: {value!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the value {var_path} with jq "{jq_query}" should be equal to {expected}')
|
||||
def step_impl(context: Context, var_path: str, jq_query: str, expected: str):
|
||||
value, result = apply_value_with_jq(
|
||||
@@ -615,13 +732,14 @@ def step_impl(context: Context, var_path: str, jq_query: str, expected: str):
|
||||
|
||||
@then('the value {var_path} with jq "{jq_query}" should match pattern {regex}')
|
||||
def step_impl(context: Context, var_path: str, jq_query: str, regex: str):
|
||||
actual_regex = replace_vars(regex, context.vars)
|
||||
value, result = apply_value_with_jq(
|
||||
context=context,
|
||||
var_path=var_path,
|
||||
jq_query=jq_query,
|
||||
)
|
||||
assert re.match(replace_vars(regex, context.vars), result), (
|
||||
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {regex!r}"
|
||||
assert re.match(actual_regex, result), (
|
||||
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {actual_regex!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -656,6 +774,15 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str):
|
||||
context.vars[var_name] = value
|
||||
|
||||
|
||||
@then("I get a new-nonce as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
acme_client = context.acme_client
|
||||
nonce = acme_client.net._get_nonce(
|
||||
url=None, new_nonce_url=acme_client.directory.newNonce
|
||||
)
|
||||
context.vars[var_name] = json_util.encode_b64jose(nonce)
|
||||
|
||||
|
||||
@then("I peak and memorize the next nonce as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
acme_client = context.acme_client
|
||||
@@ -729,22 +856,39 @@ def select_challenge(
|
||||
return challenges[0]
|
||||
|
||||
|
||||
def serve_challenge(
|
||||
def serve_challenges(
|
||||
context: Context,
|
||||
challenge: messages.ChallengeBody,
|
||||
challenges: list[messages.ChallengeBody],
|
||||
wait_time: int | None = None,
|
||||
):
|
||||
if hasattr(context, "web_server"):
|
||||
context.web_server.shutdown_and_server_close()
|
||||
|
||||
response, validation = challenge.response_and_validation(
|
||||
context.acme_client.net.key
|
||||
)
|
||||
resource = standalone.HTTP01RequestHandler.HTTP01Resource(
|
||||
chall=challenge.chall, response=response, validation=validation
|
||||
)
|
||||
resources = set()
|
||||
for challenge in challenges:
|
||||
response, validation = challenge.response_and_validation(
|
||||
context.acme_client.net.key
|
||||
)
|
||||
resources.add(
|
||||
standalone.HTTP01RequestHandler.HTTP01Resource(
|
||||
chall=challenge.chall, response=response, validation=validation
|
||||
)
|
||||
)
|
||||
# TODO: make port configurable
|
||||
servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource})
|
||||
servers.serve_forever()
|
||||
servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), resources)
|
||||
if wait_time is None:
|
||||
servers.serve_forever()
|
||||
else:
|
||||
|
||||
def wait_and_start():
|
||||
logger.info("Waiting %s seconds before we start serving.", wait_time)
|
||||
time.sleep(wait_time)
|
||||
logger.info("Start server now")
|
||||
servers.serve_forever()
|
||||
|
||||
thread = threading.Thread(target=wait_and_start)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
context.web_server = servers
|
||||
|
||||
|
||||
@@ -797,6 +941,7 @@ def step_impl(
|
||||
f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}"
|
||||
)
|
||||
|
||||
challenges = {}
|
||||
for domain in order.body.identifiers:
|
||||
logger.info(
|
||||
"Selecting challenge for domain %s with type %s ...",
|
||||
@@ -821,18 +966,28 @@ def step_impl(
|
||||
domain.value,
|
||||
challenge_type,
|
||||
)
|
||||
serve_challenge(context=context, challenge=challenge)
|
||||
challenges[domain] = challenge
|
||||
|
||||
serve_challenges(context=context, challenges=list(challenges.values()))
|
||||
for domain, challenge in challenges.items():
|
||||
logger.info(
|
||||
"Notifying challenge for domain %s with type %s ...", domain, challenge_type
|
||||
)
|
||||
notify_challenge_ready(context=context, challenge=challenge)
|
||||
|
||||
|
||||
@then(
|
||||
"I wait {wait_time} seconds and serve challenge response for {var_path} at {hostname}"
|
||||
)
|
||||
def step_impl(context: Context, wait_time: str, var_path: str, hostname: str):
|
||||
challenge = eval_var(context, var_path, as_json=False)
|
||||
serve_challenges(context=context, challenges=[challenge], wait_time=int(wait_time))
|
||||
|
||||
|
||||
@then("I serve challenge response for {var_path} at {hostname}")
|
||||
def step_impl(context: Context, var_path: str, hostname: str):
|
||||
challenge = eval_var(context, var_path, as_json=False)
|
||||
serve_challenge(context=context, challenge=challenge)
|
||||
serve_challenges(context=context, challenges=[challenge])
|
||||
|
||||
|
||||
@then("I tell ACME server that {var_path} is ready to be verified")
|
||||
@@ -841,12 +996,57 @@ def step_impl(context: Context, var_path: str):
|
||||
notify_challenge_ready(context=context, challenge=challenge)
|
||||
|
||||
|
||||
@then("I wait until the status of order {order_var} becomes {status}")
|
||||
def step_impl(context: Context, order_var: str, status: str):
|
||||
acme_client = context.acme_client
|
||||
attempt_count = 6
|
||||
while attempt_count:
|
||||
order = eval_var(context, order_var, as_json=False)
|
||||
response = acme_client._post_as_get(
|
||||
order.uri if isinstance(order, messages.OrderResource) else order
|
||||
)
|
||||
order = messages.Order.from_json(response.json())
|
||||
if order.status.name == status:
|
||||
return
|
||||
attempt_count -= 1
|
||||
time.sleep(10)
|
||||
raise TimeoutError(f"The status of order doesn't become {status} before timeout")
|
||||
|
||||
|
||||
@then("I wait until the status of authorization {auth_var} becomes {status}")
|
||||
def step_impl(context: Context, auth_var: str, status: str):
|
||||
acme_client = context.acme_client
|
||||
attempt_count = 6
|
||||
while attempt_count:
|
||||
auth = eval_var(context, auth_var, as_json=False)
|
||||
response = acme_client._post_as_get(
|
||||
auth.uri if isinstance(auth, messages.Authorization) else auth
|
||||
)
|
||||
auth = messages.Authorization.from_json(response.json())
|
||||
if auth.status.name == status:
|
||||
return
|
||||
attempt_count -= 1
|
||||
time.sleep(10)
|
||||
raise TimeoutError(f"The status of auth doesn't become {status} before timeout")
|
||||
|
||||
|
||||
@then("I post-as-get {uri} as {resp_var}")
|
||||
def step_impl(context: Context, uri: str, resp_var: str):
|
||||
acme_client = context.acme_client
|
||||
response = acme_client._post_as_get(replace_vars(uri, vars=context.vars))
|
||||
context.vars[resp_var] = response.json()
|
||||
|
||||
|
||||
@then("I poll and finalize the ACME order {var_path} as {finalized_var}")
|
||||
def step_impl(context: Context, var_path: str, finalized_var: str):
|
||||
order = eval_var(context, var_path, as_json=False)
|
||||
acme_client = context.acme_client
|
||||
finalized_order = acme_client.poll_and_finalize(order)
|
||||
context.vars[finalized_var] = finalized_order
|
||||
try:
|
||||
finalized_order = acme_client.poll_and_finalize(order)
|
||||
context.vars[finalized_var] = finalized_order
|
||||
except Exception as exp:
|
||||
logger.error(f"Failed to finalize order: {exp}", exc_info=True)
|
||||
context.vars["error"] = exp
|
||||
|
||||
|
||||
@then("I parse the full-chain certificate from order {order_var_path} as {cert_var}")
|
||||
|
||||
@@ -15,6 +15,7 @@ from josepy import JSONObjectWithFields
|
||||
|
||||
ACC_KEY_BITS = 2048
|
||||
ACC_KEY_PUBLIC_EXPONENT = 65537
|
||||
NOCK_API_PREFIX = "/api/__bdd_nock__"
|
||||
logger = logging.getLogger(__name__)
|
||||
faker = Faker()
|
||||
|
||||
@@ -265,7 +266,7 @@ def x509_cert_to_dict(cert: x509.Certificate) -> dict:
|
||||
def define_nock(context: Context, definitions: list[dict]):
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/bdd-nock/define",
|
||||
f"{NOCK_API_PREFIX}/define",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json=dict(definitions=definitions),
|
||||
)
|
||||
@@ -275,7 +276,7 @@ def define_nock(context: Context, definitions: list[dict]):
|
||||
def restore_nock(context: Context):
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/bdd-nock/restore",
|
||||
f"{NOCK_API_PREFIX}/restore",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json=dict(),
|
||||
)
|
||||
@@ -285,7 +286,7 @@ def restore_nock(context: Context):
|
||||
def clean_all_nock(context: Context):
|
||||
jwt_token = context.vars["AUTH_TOKEN"]
|
||||
response = context.http_client.post(
|
||||
"/api/v1/bdd-nock/clean-all",
|
||||
f"{NOCK_API_PREFIX}/clean-all",
|
||||
headers=dict(authorization="Bearer {}".format(jwt_token)),
|
||||
json=dict(),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { seedData1 } from "@app/db/seed-data";
|
||||
import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
|
||||
|
||||
const createPolicy = async (dto: { name: string; secretPath: string; approvers: {type: ApproverType.User, id: string}[]; approvals: number }) => {
|
||||
const createPolicy = async (dto: {
|
||||
name: string;
|
||||
secretPath: string;
|
||||
approvers: { type: ApproverType.User; id: string }[];
|
||||
approvals: number;
|
||||
}) => {
|
||||
const res = await testServer.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/secret-approvals`,
|
||||
@@ -27,7 +32,7 @@ describe("Secret approval policy router", async () => {
|
||||
const policy = await createPolicy({
|
||||
secretPath: "/",
|
||||
approvals: 1,
|
||||
approvers: [{id:seedData1.id, type: ApproverType.User}],
|
||||
approvers: [{ id: seedData1.id, type: ApproverType.User }],
|
||||
name: "test-policy"
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"watch": [
|
||||
"src"
|
||||
],
|
||||
"ext": ".ts,.js",
|
||||
"ignore": [],
|
||||
"exec": "tsx ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine"
|
||||
"exec": "tsx --tsconfig=./tsconfig.dev.json --inspect=0.0.0.0:9229 ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine"
|
||||
}
|
||||
Generated
+72
-3
@@ -128,6 +128,7 @@
|
||||
"sjcl": "^1.0.8",
|
||||
"smee-client": "^2.0.0",
|
||||
"snowflake-sdk": "^1.14.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tedious": "^18.2.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
@@ -164,6 +165,7 @@
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
@@ -15634,6 +15636,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sshpk": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz",
|
||||
@@ -18061,6 +18090,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
|
||||
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
|
||||
@@ -18901,6 +18939,20 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-hash": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||
@@ -24996,9 +25048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
|
||||
"integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==",
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
|
||||
"integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -31492,6 +31544,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"outputPath": "binary"
|
||||
},
|
||||
"scripts": {
|
||||
"assets:export": "./scripts/export-assets.sh",
|
||||
"binary:build": "npm run binary:clean && npm run build:frontend && npm run build && npm run binary:babel-frontend && npm run binary:babel-backend && npm run binary:rename-imports",
|
||||
"binary:package": "pkg --no-bytecode --public-packages \"*\" --public --target host .",
|
||||
"binary:babel-backend": " babel ./dist -d ./dist",
|
||||
@@ -32,7 +33,7 @@
|
||||
"binary:clean": "rm -rf ./dist && rm -rf ./binary",
|
||||
"binary:rename-imports": "ts-node ./scripts/rename-mjs.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "tsx watch --clear-screen=false ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine",
|
||||
"dev": "tsx watch --clear-screen=false ./src/main.ts --config tsconfig.dev.json | pino-pretty --colorize --colorizeObjects --singleLine",
|
||||
"dev:docker": "nodemon",
|
||||
"build": "tsup --sourcemap",
|
||||
"build:frontend": "npm run build --prefix ../frontend",
|
||||
@@ -110,6 +111,7 @@
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
@@ -257,6 +259,7 @@
|
||||
"sjcl": "^1.0.8",
|
||||
"smee-client": "^2.0.0",
|
||||
"snowflake-sdk": "^1.14.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tedious": "^18.2.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
import promptSync from "prompt-sync";
|
||||
import slugify from "@sindresorhus/slugify"
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
const prompt = promptSync({ sigint: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# Export frontend static assets for CDN deployment
|
||||
# Usage:
|
||||
# npm run assets:export - Output tar to stdout (pipe to file or aws s3)
|
||||
# npm run assets:export /path - Extract assets to specified directory
|
||||
# npm run assets:export -- --help - Show usage
|
||||
|
||||
set -e
|
||||
|
||||
ASSETS_PATH="/backend/frontend-build/assets"
|
||||
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
Export frontend static assets for CDN deployment.
|
||||
|
||||
USAGE:
|
||||
docker run --rm infisical/infisical npm run --silent assets:export [-- OPTIONS] [PATH]
|
||||
|
||||
OPTIONS:
|
||||
--help, -h Show this help message
|
||||
|
||||
ARGUMENTS:
|
||||
PATH Directory to export assets to. If not provided, outputs
|
||||
a tar archive to stdout.
|
||||
|
||||
NOTE:
|
||||
Use --silent flag to suppress npm output when piping to stdout.
|
||||
|
||||
EXAMPLES:
|
||||
# Export as tar to local file
|
||||
docker run --rm infisical/infisical npm run --silent assets:export > assets.tar
|
||||
|
||||
# Extract to local directory
|
||||
docker run --rm -v $(pwd)/cdn-assets:/output infisical/infisical npm run --silent assets:export /output
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check for help flag
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
|
||||
# Verify assets exist
|
||||
if [ ! -d "$ASSETS_PATH" ]; then
|
||||
echo "Error: Assets directory not found at $ASSETS_PATH" >&2
|
||||
echo "Make sure the frontend is built and included in the image." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSET_COUNT=$(find "$ASSETS_PATH" -type f | wc -l | tr -d ' ')
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
# No path provided - output tar to stdout
|
||||
echo "Exporting $ASSET_COUNT assets as tar archive to stdout..." >&2
|
||||
tar -cf - -C "$(dirname "$ASSETS_PATH")" "$(basename "$ASSETS_PATH")"
|
||||
else
|
||||
# Path provided - extract to directory
|
||||
OUTPUT_PATH="$1"
|
||||
|
||||
if [ ! -d "$OUTPUT_PATH" ]; then
|
||||
echo "Creating output directory: $OUTPUT_PATH" >&2
|
||||
mkdir -p "$OUTPUT_PATH"
|
||||
fi
|
||||
|
||||
echo "Exporting $ASSET_COUNT assets to $OUTPUT_PATH..." >&2
|
||||
cp -r "$ASSETS_PATH"/* "$OUTPUT_PATH/"
|
||||
|
||||
echo "✅ Assets exported successfully!" >&2
|
||||
echo " Path: $OUTPUT_PATH" >&2
|
||||
echo " Files: $ASSET_COUNT assets" >&2
|
||||
fi
|
||||
Vendored
+2
@@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a
|
||||
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
|
||||
import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
|
||||
import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
|
||||
import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
|
||||
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
|
||||
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
|
||||
@@ -288,6 +289,7 @@ declare module "fastify" {
|
||||
auditLogStream: TAuditLogStreamServiceFactory;
|
||||
certificate: TCertificateServiceFactory;
|
||||
certificateV3: TCertificateV3ServiceFactory;
|
||||
certificateRequest: TCertificateRequestServiceFactory;
|
||||
certificateTemplate: TCertificateTemplateServiceFactory;
|
||||
certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
|
||||
certificateProfile: TCertificateProfileServiceFactory;
|
||||
|
||||
Vendored
+10
@@ -573,6 +573,11 @@ import {
|
||||
TWorkflowIntegrationsInsert,
|
||||
TWorkflowIntegrationsUpdate
|
||||
} from "@app/db/schemas";
|
||||
import {
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
} from "@app/db/schemas/certificate-requests";
|
||||
import {
|
||||
TAccessApprovalPoliciesEnvironments,
|
||||
TAccessApprovalPoliciesEnvironmentsInsert,
|
||||
@@ -714,6 +719,11 @@ declare module "knex/types/tables" {
|
||||
TExternalCertificateAuthoritiesUpdate
|
||||
>;
|
||||
[TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
|
||||
[TableName.CertificateRequests]: KnexOriginal.CompositeTableType<
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
>;
|
||||
[TableName.CertificateTemplate]: KnexOriginal.CompositeTableType<
|
||||
TCertificateTemplates,
|
||||
TCertificateTemplatesInsert,
|
||||
|
||||
@@ -14,13 +14,16 @@ export async function up(knex: Knex): Promise<void> {
|
||||
if (rows.length > 0) {
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
const batch = rows.slice(i, i + BATCH_SIZE);
|
||||
const ids = batch.map((row) => row.id);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.SecretApprovalPolicy)
|
||||
.whereIn(
|
||||
"id",
|
||||
batch.map((row) => row.id)
|
||||
)
|
||||
.update({ shouldCheckSecretPermission: true });
|
||||
await knex.raw(
|
||||
`
|
||||
UPDATE ??
|
||||
SET ?? = true
|
||||
WHERE ?? IN (${ids.map(() => "?").join(",")})
|
||||
`,
|
||||
[TableName.SecretApprovalPolicy, "shouldCheckSecretPermission", "id", ids]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
const CONSTRAINT_NAME = "unique_pki_acme_account_public_key_and_profile_id";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) {
|
||||
const hasProfileId = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "profileId");
|
||||
const hasPublicKeyThumbprint = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "publicKeyThumbprint");
|
||||
|
||||
if (hasProfileId && hasPublicKeyThumbprint) {
|
||||
await knex.schema.alterTable(TableName.PkiAcmeAccount, (table) => {
|
||||
table.unique(["profileId", "publicKeyThumbprint"], { indexName: CONSTRAINT_NAME });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) {
|
||||
const hasProfileId = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "profileId");
|
||||
const hasPublicKeyThumbprint = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "publicKeyThumbprint");
|
||||
|
||||
await knex.schema.alterTable(TableName.PkiAcmeAccount, async () => {
|
||||
if (hasProfileId && hasPublicKeyThumbprint) {
|
||||
await dropConstraintIfExists(TableName.PkiAcmeAccount, CONSTRAINT_NAME, knex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
|
||||
t.dropColumn("shouldCheckSecretPermission");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission"))) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
|
||||
t.boolean("shouldCheckSecretPermission").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType");
|
||||
|
||||
if (!hasIssuerTypeColumn) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.string("issuerType").notNullable().defaultTo("ca");
|
||||
});
|
||||
}
|
||||
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.uuid("caId").nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType");
|
||||
|
||||
if (hasIssuerTypeColumn) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.dropColumn("issuerType");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable(TableName.ScimToken, (t) => {
|
||||
t.boolean("expiryNotificationSent").defaultTo(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent");
|
||||
if (hasCol) {
|
||||
await knex.schema.alterTable(TableName.ScimToken, (t) => {
|
||||
t.dropColumn("expiryNotificationSent");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.CertificateRequests))) {
|
||||
await knex.schema.createTable(TableName.CertificateRequests, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.string("status").notNullable();
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.uuid("profileId").nullable();
|
||||
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL");
|
||||
t.uuid("caId").nullable();
|
||||
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL");
|
||||
t.uuid("certificateId").nullable();
|
||||
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||
t.text("csr").nullable();
|
||||
t.string("commonName").nullable();
|
||||
t.text("altNames").nullable();
|
||||
t.specificType("keyUsages", "text[]").nullable();
|
||||
t.specificType("extendedKeyUsages", "text[]").nullable();
|
||||
t.datetime("notBefore").nullable();
|
||||
t.datetime("notAfter").nullable();
|
||||
t.string("keyAlgorithm").nullable();
|
||||
t.string("signatureAlgorithm").nullable();
|
||||
t.text("errorMessage").nullable();
|
||||
t.text("metadata").nullable();
|
||||
|
||||
t.index(["projectId"]);
|
||||
t.index(["status"]);
|
||||
t.index(["profileId"]);
|
||||
t.index(["caId"]);
|
||||
t.index(["certificateId"]);
|
||||
t.index(["createdAt"]);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.CertificateRequests);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.CertificateRequests);
|
||||
await dropOnUpdateTrigger(knex, TableName.CertificateRequests);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
|
||||
if (!hasExternalConfigs) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.text("externalConfigs").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
|
||||
if (hasExternalConfigs) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.dropColumn("externalConfigs");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const CertificateRequestsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
status: z.string(),
|
||||
projectId: z.string(),
|
||||
profileId: z.string().uuid().nullable().optional(),
|
||||
caId: z.string().uuid().nullable().optional(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
csr: z.string().nullable().optional(),
|
||||
commonName: z.string().nullable().optional(),
|
||||
altNames: z.string().nullable().optional(),
|
||||
keyUsages: z.string().array().nullable().optional(),
|
||||
extendedKeyUsages: z.string().array().nullable().optional(),
|
||||
notBefore: z.date().nullable().optional(),
|
||||
notAfter: z.date().nullable().optional(),
|
||||
keyAlgorithm: z.string().nullable().optional(),
|
||||
signatureAlgorithm: z.string().nullable().optional(),
|
||||
errorMessage: z.string().nullable().optional(),
|
||||
metadata: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TCertificateRequests = z.infer<typeof CertificateRequestsSchema>;
|
||||
export type TCertificateRequestsInsert = Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>;
|
||||
export type TCertificateRequestsUpdate = Partial<Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>>;
|
||||
@@ -16,6 +16,7 @@ export * from "./certificate-authority-certs";
|
||||
export * from "./certificate-authority-crl";
|
||||
export * from "./certificate-authority-secret";
|
||||
export * from "./certificate-bodies";
|
||||
export * from "./certificate-requests";
|
||||
export * from "./certificate-secrets";
|
||||
export * from "./certificate-syncs";
|
||||
export * from "./certificate-template-est-configs";
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum TableName {
|
||||
CertificateAuthorityCrl = "certificate_authority_crl",
|
||||
Certificate = "certificates",
|
||||
CertificateBody = "certificate_bodies",
|
||||
CertificateRequests = "certificate_requests",
|
||||
CertificateSecret = "certificate_secrets",
|
||||
CertificateTemplate = "certificate_templates",
|
||||
PkiCertificateTemplateV2 = "pki_certificate_templates_v2",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const PkiCertificateProfilesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
caId: z.string().uuid(),
|
||||
caId: z.string().uuid().nullable().optional(),
|
||||
certificateTemplateId: z.string().uuid(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
@@ -19,7 +19,9 @@ export const PkiCertificateProfilesSchema = z.object({
|
||||
apiConfigId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
acmeConfigId: z.string().uuid().nullable().optional()
|
||||
acmeConfigId: z.string().uuid().nullable().optional(),
|
||||
issuerType: z.string().default("ca"),
|
||||
externalConfigs: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
|
||||
|
||||
@@ -13,7 +13,8 @@ export const ScimTokensSchema = z.object({
|
||||
description: z.string(),
|
||||
orgId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
expiryNotificationSent: z.boolean().default(false).nullable().optional()
|
||||
});
|
||||
|
||||
export type TScimTokens = z.infer<typeof ScimTokensSchema>;
|
||||
|
||||
@@ -17,8 +17,7 @@ export const SecretApprovalPoliciesSchema = z.object({
|
||||
updatedAt: z.date(),
|
||||
enforcementLevel: z.string().default("hard"),
|
||||
deletedAt: z.date().nullable().optional(),
|
||||
allowedSelfApprovals: z.boolean().default(true),
|
||||
shouldCheckSecretPermission: z.boolean().nullable().optional()
|
||||
allowedSelfApprovals: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
|
||||
|
||||
@@ -4,15 +4,10 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import {
|
||||
ExternalKmsAwsSchema,
|
||||
ExternalKmsGcpCredentialSchema,
|
||||
ExternalKmsGcpSchema,
|
||||
ExternalKmsInputSchema,
|
||||
ExternalKmsInputUpdateSchema,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsProviders,
|
||||
TExternalKmsGcpCredentialSchema
|
||||
ExternalKmsInputUpdateSchema
|
||||
} from "@app/ee/services/external-kms/providers/model";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -293,67 +288,4 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => {
|
||||
return { externalKms };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/gcp/keys",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential),
|
||||
region: z.string().trim().min(1),
|
||||
credential: ExternalKmsGcpCredentialSchema
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms),
|
||||
region: z.string().trim().min(1),
|
||||
kmsId: z.string().trim().min(1)
|
||||
})
|
||||
]),
|
||||
response: {
|
||||
200: z.object({
|
||||
keys: z.string().array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { region, authMethod } = req.body;
|
||||
let credentialJson: TExternalKmsGcpCredentialSchema | undefined;
|
||||
|
||||
if (authMethod === KmsGcpKeyFetchAuthType.Credential) {
|
||||
credentialJson = req.body.credential;
|
||||
} else if (authMethod === KmsGcpKeyFetchAuthType.Kms) {
|
||||
const externalKms = await server.services.externalKms.findById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.body.kmsId
|
||||
});
|
||||
|
||||
if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) {
|
||||
throw new NotFoundError({ message: "KMS not found or not of type GCP" });
|
||||
}
|
||||
|
||||
credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema;
|
||||
}
|
||||
|
||||
if (!credentialJson) {
|
||||
throw new NotFoundError({
|
||||
message: "Something went wrong while fetching the GCP credential, please check inputs and try again"
|
||||
});
|
||||
}
|
||||
|
||||
const results = await server.services.externalKms.fetchGcpKeys({
|
||||
credential: credentialJson,
|
||||
gcpRegion: region
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ExternalKmsAwsSchema, KmsProviders } from "@app/ee/services/external-kms/providers/model";
|
||||
|
||||
import { registerExternalKmsEndpoints } from "./external-kms-endpoints";
|
||||
|
||||
export const registerAwsKmsRouter = async (server: FastifyZodProvider) => {
|
||||
registerExternalKmsEndpoints({
|
||||
server,
|
||||
provider: KmsProviders.Aws,
|
||||
createSchema: ExternalKmsAwsSchema,
|
||||
updateSchema: ExternalKmsAwsSchema.partial()
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import {
|
||||
KmsProviders,
|
||||
SanitizedExternalKmsAwsSchema,
|
||||
SanitizedExternalKmsGcpSchema,
|
||||
TExternalKmsInputSchema,
|
||||
TExternalKmsInputUpdateSchema
|
||||
} from "@app/ee/services/external-kms/providers/model";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const sanitizedExternalSchema = KmsKeysSchema.extend({
|
||||
externalKms: ExternalKmsSchema.pick({
|
||||
id: true,
|
||||
status: true,
|
||||
statusDetails: true,
|
||||
provider: true
|
||||
}).extend({
|
||||
configuration: z.union([SanitizedExternalKmsAwsSchema, SanitizedExternalKmsGcpSchema]),
|
||||
credentialsHash: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
export const registerExternalKmsEndpoints = <
|
||||
T extends { type: KmsProviders; inputs: TExternalKmsInputSchema["inputs"] }
|
||||
>({
|
||||
server,
|
||||
provider,
|
||||
createSchema,
|
||||
updateSchema
|
||||
}: {
|
||||
server: FastifyZodProvider;
|
||||
provider: T["type"];
|
||||
createSchema: z.ZodType<T["inputs"]>;
|
||||
updateSchema: z.ZodType<Partial<T["inputs"]>>;
|
||||
}) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().trim().min(1)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedExternalSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const externalKms = await server.services.externalKms.findById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
// Validate that the KMS is of the expected provider type
|
||||
if (externalKms.external.provider !== provider) {
|
||||
throw new BadRequestError({
|
||||
message: `KMS provider mismatch. Expected ${provider}, got ${externalKms.external.provider}`
|
||||
});
|
||||
}
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.GET_KMS,
|
||||
metadata: {
|
||||
kmsId: externalKms.id,
|
||||
name: externalKms.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
external: { providerInput: configuration, ...externalKmsData },
|
||||
...rest
|
||||
} = externalKms;
|
||||
|
||||
const credentialsHash = crypto.nativeCrypto
|
||||
.createHash("sha256")
|
||||
.update(externalKmsData.encryptedProviderInputs)
|
||||
.digest("hex");
|
||||
return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string().min(1).trim().toLowerCase(),
|
||||
description: z.string().trim().optional(),
|
||||
configuration: createSchema
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedExternalSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { name, description, configuration } = req.body as {
|
||||
name: string;
|
||||
description?: string;
|
||||
configuration: T["inputs"];
|
||||
};
|
||||
|
||||
const providerInput = {
|
||||
type: provider,
|
||||
inputs: configuration
|
||||
} as TExternalKmsInputSchema;
|
||||
|
||||
const externalKms = await server.services.externalKms.create({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
name,
|
||||
provider: providerInput,
|
||||
description
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.CREATE_KMS,
|
||||
metadata: {
|
||||
kmsId: externalKms.id,
|
||||
provider,
|
||||
name,
|
||||
description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
external: { providerInput: externalKmsConfiguration, ...externalKmsData },
|
||||
...rest
|
||||
} = externalKms;
|
||||
const credentialsHash = crypto.nativeCrypto
|
||||
.createHash("sha256")
|
||||
.update(externalKmsData.encryptedProviderInputs)
|
||||
.digest("hex");
|
||||
return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:id",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().trim().min(1)
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().min(1).trim().toLowerCase().optional(),
|
||||
description: z.string().trim().optional(),
|
||||
configuration: updateSchema.optional()
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedExternalSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { name, description, configuration } = req.body as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
configuration: Partial<T["inputs"]>;
|
||||
};
|
||||
|
||||
const providerInput = {
|
||||
type: provider,
|
||||
inputs: configuration
|
||||
} as TExternalKmsInputUpdateSchema;
|
||||
|
||||
const externalKms = await server.services.externalKms.updateById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
name,
|
||||
provider: providerInput,
|
||||
description,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.UPDATE_KMS,
|
||||
metadata: {
|
||||
kmsId: externalKms.id,
|
||||
provider,
|
||||
name,
|
||||
description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
external: { providerInput: externalKmsConfiguration, ...externalKmsData },
|
||||
...rest
|
||||
} = externalKms;
|
||||
const credentialsHash = crypto.nativeCrypto
|
||||
.createHash("sha256")
|
||||
.update(externalKmsData.encryptedProviderInputs)
|
||||
.digest("hex");
|
||||
return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:id",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().trim().min(1)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedExternalSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const externalKms = await server.services.externalKms.deleteById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
// Validate that the KMS is of the expected provider type
|
||||
if (externalKms.external.provider !== provider) {
|
||||
throw new BadRequestError({
|
||||
message: `KMS provider mismatch. Expected ${provider}, got ${externalKms.external.provider}`
|
||||
});
|
||||
}
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.DELETE_KMS,
|
||||
metadata: {
|
||||
kmsId: externalKms.id,
|
||||
name: externalKms.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
external: { providerInput: configuration, ...externalKmsData },
|
||||
...rest
|
||||
} = externalKms;
|
||||
const credentialsHash = crypto.nativeCrypto
|
||||
.createHash("sha256")
|
||||
.update(externalKmsData.encryptedProviderInputs)
|
||||
.digest("hex");
|
||||
|
||||
return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ExternalKmsGcpCredentialSchema,
|
||||
ExternalKmsGcpSchema,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsProviders,
|
||||
TExternalKmsGcpCredentialSchema
|
||||
} from "@app/ee/services/external-kms/providers/model";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
import { registerExternalKmsEndpoints } from "./external-kms-endpoints";
|
||||
|
||||
export const registerGcpKmsRouter = async (server: FastifyZodProvider) => {
|
||||
registerExternalKmsEndpoints({
|
||||
server,
|
||||
provider: KmsProviders.Gcp,
|
||||
createSchema: ExternalKmsGcpSchema,
|
||||
updateSchema: ExternalKmsGcpSchema.partial()
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/keys",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential),
|
||||
region: z.string().trim().min(1),
|
||||
credential: ExternalKmsGcpCredentialSchema
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms),
|
||||
region: z.string().trim().min(1),
|
||||
kmsId: z.string().trim().min(1)
|
||||
})
|
||||
]),
|
||||
response: {
|
||||
200: z.object({
|
||||
keys: z.string().array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { region, authMethod } = req.body;
|
||||
let credentialJson: TExternalKmsGcpCredentialSchema | undefined;
|
||||
|
||||
if (authMethod === KmsGcpKeyFetchAuthType.Credential && "credential" in req.body) {
|
||||
credentialJson = req.body.credential;
|
||||
} else if (authMethod === KmsGcpKeyFetchAuthType.Kms && "kmsId" in req.body) {
|
||||
const externalKms = await server.services.externalKms.findById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.body.kmsId
|
||||
});
|
||||
|
||||
if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) {
|
||||
throw new NotFoundError({ message: "KMS not found or not of type GCP" });
|
||||
}
|
||||
|
||||
const providerInput = externalKms.external.providerInput as { credential: TExternalKmsGcpCredentialSchema };
|
||||
credentialJson = providerInput.credential;
|
||||
}
|
||||
|
||||
if (!credentialJson) {
|
||||
throw new NotFoundError({
|
||||
message: "Something went wrong while fetching the GCP credential, please check inputs and try again"
|
||||
});
|
||||
}
|
||||
|
||||
const results = await server.services.externalKms.fetchGcpKeys({
|
||||
credential: credentialJson,
|
||||
gcpRegion: region
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { KmsProviders } from "@app/ee/services/external-kms/providers/model";
|
||||
|
||||
import { registerAwsKmsRouter } from "./aws-kms-router";
|
||||
import { registerGcpKmsRouter } from "./gcp-kms-router";
|
||||
|
||||
export const EXTERNAL_KMS_REGISTER_ROUTER_MAP: Record<KmsProviders, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[KmsProviders.Aws]: registerAwsKmsRouter,
|
||||
[KmsProviders.Gcp]: registerGcpKmsRouter
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas";
|
||||
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
|
||||
import {
|
||||
EFilterReturnedProjects,
|
||||
EFilterReturnedUsers,
|
||||
EGroupProjectsOrderBy
|
||||
} from "@app/ee/services/group/group-types";
|
||||
import { ApiDocsTags, GROUPS } from "@app/lib/api-docs";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -11,6 +17,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "POST",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -40,6 +49,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/:id",
|
||||
method: "GET",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -69,6 +81,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -93,6 +108,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/:id",
|
||||
method: "PATCH",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -128,6 +146,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/:id",
|
||||
method: "DELETE",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -155,6 +176,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/users",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -163,7 +187,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
id: z.string().trim().describe(GROUPS.LIST_USERS.id)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset),
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
|
||||
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
|
||||
@@ -203,9 +227,72 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/projects",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.Groups],
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.LIST_PROJECTS.id)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects),
|
||||
orderBy: z
|
||||
.nativeEnum(EGroupProjectsOrderBy)
|
||||
.default(EGroupProjectsOrderBy.Name)
|
||||
.describe(GROUPS.LIST_PROJECTS.orderBy),
|
||||
orderDirection: z
|
||||
.nativeEnum(OrderByDirection)
|
||||
.default(OrderByDirection.ASC)
|
||||
.describe(GROUPS.LIST_PROJECTS.orderDirection)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
projects: ProjectsSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
type: true
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
)
|
||||
.array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { projects, totalCount } = await server.services.group.listGroupProjects({
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.query
|
||||
});
|
||||
|
||||
return { projects, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:id/users/:username",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
@@ -241,6 +328,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:id/users/:username",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"
|
||||
import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router";
|
||||
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
||||
import { registerExternalKmsRouter } from "./external-kms-router";
|
||||
|
||||
import { EXTERNAL_KMS_REGISTER_ROUTER_MAP } from "./external-kms-routers";
|
||||
import { registerGatewayRouter } from "./gateway-router";
|
||||
import { registerGithubOrgSyncRouter } from "./github-org-sync-router";
|
||||
import { registerGroupRouter } from "./group-router";
|
||||
@@ -110,7 +112,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" });
|
||||
await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" });
|
||||
},
|
||||
{ prefix: "/pki" }
|
||||
{ prefix: "/cert-manager" }
|
||||
);
|
||||
|
||||
await server.register(
|
||||
@@ -162,9 +164,19 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
{ prefix: "/additional-privilege" }
|
||||
);
|
||||
|
||||
await server.register(registerExternalKmsRouter, {
|
||||
prefix: "/external-kms"
|
||||
});
|
||||
await server.register(
|
||||
async (externalKmsRouter) => {
|
||||
await externalKmsRouter.register(registerExternalKmsRouter);
|
||||
|
||||
// Provider-specific endpoints
|
||||
await Promise.all(
|
||||
Object.entries(EXTERNAL_KMS_REGISTER_ROUTER_MAP).map(([provider, router]) =>
|
||||
externalKmsRouter.register(router, { prefix: `/${provider}` })
|
||||
)
|
||||
);
|
||||
},
|
||||
{ prefix: "/external-kms" }
|
||||
);
|
||||
await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" });
|
||||
|
||||
await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" });
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
UpdatePostgresAccountSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
CreateSSHAccountSchema,
|
||||
SanitizedSSHAccountWithResourceSchema,
|
||||
UpdateSSHAccountSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
|
||||
|
||||
@@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
|
||||
createAccountSchema: CreateMySQLAccountSchema,
|
||||
updateAccountSchema: UpdateMySQLAccountSchema
|
||||
});
|
||||
},
|
||||
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.SSH,
|
||||
accountResponseSchema: SanitizedSSHAccountWithResourceSchema,
|
||||
createAccountSchema: CreateSSHAccountSchema,
|
||||
updateAccountSchema: UpdateSSHAccountSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,16 +2,21 @@ import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
|
||||
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SanitizedAccountSchema = z.union([
|
||||
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
SanitizedMySQLAccountWithResourceSchema
|
||||
]);
|
||||
@@ -26,33 +31,69 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "List PAM accounts",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
projectId: z.string().uuid(),
|
||||
accountPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
accountView: z.nativeEnum(PamAccountView).default(PamAccountView.Flat),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
limit: z.coerce.number().min(1).max(100).default(100),
|
||||
orderBy: z.nativeEnum(PamAccountOrderBy).default(PamAccountOrderBy.Name),
|
||||
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
|
||||
search: z.string().trim().optional(),
|
||||
filterResourceIds: z
|
||||
.string()
|
||||
.transform((val) =>
|
||||
val
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
accounts: SanitizedAccountSchema.array(),
|
||||
folders: PamFoldersSchema.array()
|
||||
folders: PamFoldersSchema.array(),
|
||||
totalCount: z.number().default(0),
|
||||
folderId: z.string().optional(),
|
||||
folderPaths: z.record(z.string(), z.string())
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamAccount.list(req.query.projectId, req.permission);
|
||||
const { projectId, accountPath, accountView, limit, offset, search, orderBy, orderDirection, filterResourceIds } =
|
||||
req.query;
|
||||
|
||||
const { accounts, folders, totalCount, folderId, folderPaths } = await server.services.pamAccount.list({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
accountPath,
|
||||
accountView,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
filterResourceIds
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_LIST,
|
||||
metadata: {
|
||||
accountCount: response.accounts.length,
|
||||
folderCount: response.folders.length
|
||||
accountCount: accounts.length,
|
||||
folderCount: folders.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
return { accounts, folders, totalCount, folderId, folderPaths };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,7 +106,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "Access PAM account",
|
||||
body: z.object({
|
||||
accountId: z.string().uuid(),
|
||||
accountPath: z.string().trim(),
|
||||
projectId: z.string().uuid(),
|
||||
duration: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -93,7 +135,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string(),
|
||||
metadata: z.record(z.string(), z.string()).optional()
|
||||
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -110,7 +152,9 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
actorIp: req.realIp,
|
||||
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
|
||||
actorUserAgent: req.auditLogInfo.userAgent ?? "",
|
||||
...req.body
|
||||
accountPath: req.body.accountPath,
|
||||
projectId: req.body.projectId,
|
||||
duration: req.body.duration
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
@@ -122,7 +166,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS,
|
||||
metadata: {
|
||||
accountId: req.body.accountId,
|
||||
accountId: response.account.id,
|
||||
accountPath: req.body.accountPath,
|
||||
accountName: response.account.name,
|
||||
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
SanitizedPostgresResourceSchema,
|
||||
UpdatePostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
CreateSSHResourceSchema,
|
||||
SanitizedSSHResourceSchema,
|
||||
UpdateSSHResourceSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
||||
|
||||
@@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
|
||||
createResourceSchema: CreateMySQLResourceSchema,
|
||||
updateResourceSchema: UpdateMySQLResourceSchema
|
||||
});
|
||||
},
|
||||
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.SSH,
|
||||
resourceResponseSchema: SanitizedSSHResourceSchema,
|
||||
createResourceSchema: CreateSSHResourceSchema,
|
||||
updateResourceSchema: UpdateSSHResourceSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,19 +5,30 @@ import {
|
||||
MySQLResourceListItemSchema,
|
||||
SanitizedMySQLResourceSchema
|
||||
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import {
|
||||
PostgresResourceListItemSchema,
|
||||
SanitizedPostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
SanitizedSSHResourceSchema,
|
||||
SSHResourceListItemSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]);
|
||||
const SanitizedResourceSchema = z.union([
|
||||
SanitizedPostgresResourceSchema,
|
||||
SanitizedMySQLResourceSchema,
|
||||
SanitizedSSHResourceSchema
|
||||
]);
|
||||
|
||||
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
|
||||
PostgresResourceListItemSchema,
|
||||
MySQLResourceListItemSchema
|
||||
MySQLResourceListItemSchema,
|
||||
SSHResourceListItemSchema
|
||||
]);
|
||||
|
||||
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
@@ -52,17 +63,46 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "List PAM resources",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
projectId: z.string().uuid(),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
limit: z.coerce.number().min(1).max(100).default(100),
|
||||
orderBy: z.nativeEnum(PamResourceOrderBy).default(PamResourceOrderBy.Name),
|
||||
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
|
||||
search: z.string().trim().optional(),
|
||||
filterResourceTypes: z
|
||||
.string()
|
||||
.transform((val) =>
|
||||
val
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resources: SanitizedResourceSchema.array()
|
||||
resources: SanitizedResourceSchema.array(),
|
||||
totalCount: z.number().default(0)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamResource.list(req.query.projectId, req.permission);
|
||||
const { projectId, limit, offset, search, orderBy, orderDirection, filterResourceTypes } = req.query;
|
||||
|
||||
const { resources, totalCount } = await server.services.pamResource.list({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
filterResourceTypes
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
@@ -71,12 +111,12 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_LIST,
|
||||
metadata: {
|
||||
count: response.resources.length
|
||||
count: resources.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
return { resources, totalCount };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,12 +4,21 @@ import { PamSessionsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
|
||||
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import {
|
||||
PamSessionCommandLogSchema,
|
||||
SanitizedSessionSchema,
|
||||
TerminalEventSchema
|
||||
} from "@app/ee/services/pam-session/pam-session-schemas";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]);
|
||||
const SessionCredentialsSchema = z.union([
|
||||
SSHSessionCredentialsSchema,
|
||||
PostgresSessionCredentialsSchema,
|
||||
MySQLSessionCredentialsSchema
|
||||
]);
|
||||
|
||||
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
// Meant to be hit solely by gateway identities
|
||||
@@ -32,17 +41,15 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials(
|
||||
req.params.sessionId,
|
||||
req.permission
|
||||
);
|
||||
const { credentials, projectId, account, sessionStarted } =
|
||||
await server.services.pamAccount.getSessionCredentials(req.params.sessionId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_START,
|
||||
type: EventType.PAM_SESSION_CREDENTIALS_GET,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId,
|
||||
accountName: account.name
|
||||
@@ -50,7 +57,22 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
return { credentials };
|
||||
if (sessionStarted) {
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_START,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId,
|
||||
accountName: account.name
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { credentials: credentials as z.infer<typeof SessionCredentialsSchema> };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,7 +89,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
logs: PamSessionCommandLogSchema.array()
|
||||
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -77,7 +77,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
done(error, undefined);
|
||||
}
|
||||
});
|
||||
// GET /api/v1/pki/acme/profiles/<profile_id>/directory
|
||||
|
||||
// GET /api/v1/cert-manager/acme/profiles/<profile_id>/directory
|
||||
// Directory (RFC 8555 Section 7.1.1)
|
||||
server.route({
|
||||
method: "GET",
|
||||
@@ -99,7 +100,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId)
|
||||
});
|
||||
|
||||
// HEAD /api/v1/pki/acme/profiles/<profile_id>/new-nonce
|
||||
// HEAD /api/v1/cert-manager/acme/profiles/<profile_id>/new-nonce
|
||||
// New Nonce (RFC 8555 Section 7.2)
|
||||
server.route({
|
||||
method: "HEAD",
|
||||
@@ -126,7 +127,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/new-account
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/new-account
|
||||
// New Account (RFC 8555 Section 7.3)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -163,7 +164,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/accounts/<account_id>
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/accounts/<account_id>
|
||||
// Account Deactivation (RFC 8555 Section 7.3.6)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -200,7 +201,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/new-order
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/new-order
|
||||
// New Certificate Order (RFC 8555 Section 7.4)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -235,7 +236,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/orders/<order_id>
|
||||
// Get Order (RFC 8555 Section 7.1.3)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -271,7 +272,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>/finalize
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/orders/<order_id>/finalize
|
||||
// Applying for Certificate Issuance (RFC 8555 Section 7.4)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -308,7 +309,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
);
|
||||
}
|
||||
});
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/accounts/<account_id>/orders
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/accounts/<account_id>/orders
|
||||
// List Orders (RFC 8555 Section 7.1.2.1)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -344,7 +345,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>/certificate
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/orders/<order_id>/certificate
|
||||
// Download Certificate (RFC 8555 Section 7.4.2)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -377,7 +378,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/authorizations/<authz_id>
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/authorizations/<authz_id>
|
||||
// Identifier Authorization (RFC 8555 Section 7.5)
|
||||
server.route({
|
||||
method: "POST",
|
||||
@@ -411,7 +412,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/pki/acme/profiles/<profile_id>/authorizations/<authz_id>/challenges/<challenge_id>
|
||||
// POST /api/v1/cert-manager/acme/profiles/<profile_id>/authorizations/<authz_id>/challenges/<challenge_id>
|
||||
// Respond to Challenge (RFC 8555 Section 7.5.1)
|
||||
server.route({
|
||||
method: "POST",
|
||||
|
||||
@@ -72,7 +72,6 @@ const ProjectTemplateEnvironmentsSchema = z
|
||||
position: z.number().min(1)
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.superRefine((environments, ctx) => {
|
||||
if (Buffer.byteLength(JSON.stringify(environments)) > MAX_JSON_SIZE_LIMIT_IN_BYTES)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Size limit exceeded" });
|
||||
@@ -198,7 +197,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider)
|
||||
description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description),
|
||||
roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles),
|
||||
type: z.nativeEnum(ProjectType).describe(ProjectTemplates.CREATE.type),
|
||||
environments: ProjectTemplateEnvironmentsSchema.describe(ProjectTemplates.CREATE.environments).optional()
|
||||
environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.CREATE.environments)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -243,7 +242,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider)
|
||||
.describe(ProjectTemplates.UPDATE.name),
|
||||
description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description),
|
||||
roles: ProjectTemplateRolesSchema.optional().describe(ProjectTemplates.UPDATE.roles),
|
||||
environments: ProjectTemplateEnvironmentsSchema.optional().describe(ProjectTemplates.UPDATE.environments)
|
||||
environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.UPDATE.environments)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -57,7 +57,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
body: z.object({
|
||||
organizationId: z.string().trim(),
|
||||
description: z.string().trim().default(""),
|
||||
ttlDays: z.number().min(0).default(0)
|
||||
ttlDays: z.number().min(0).max(730).default(0)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -305,8 +305,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
secretPath: z.string().optional().nullable(),
|
||||
enforcementLevel: z.string(),
|
||||
deletedAt: z.date().nullish(),
|
||||
allowedSelfApprovals: z.boolean(),
|
||||
shouldCheckSecretPermission: z.boolean().nullable().optional()
|
||||
allowedSelfApprovals: z.boolean()
|
||||
}),
|
||||
environment: z.string(),
|
||||
statusChangedByUser: approvalRequestUser.optional(),
|
||||
|
||||
@@ -84,7 +84,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privilege: {
|
||||
...privilege,
|
||||
identityId: req.body.identityId,
|
||||
projectMembershipId: req.body.projectId,
|
||||
projectId: req.body.projectId,
|
||||
slug: privilege.name
|
||||
}
|
||||
@@ -158,6 +157,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
},
|
||||
data: {
|
||||
...req.body,
|
||||
name: req.body.slug,
|
||||
...req.body.type,
|
||||
permissions: req.body.permissions || undefined
|
||||
}
|
||||
@@ -167,7 +167,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privilege: {
|
||||
...privilege,
|
||||
identityId: privilegeDoc.actorIdentityId as string,
|
||||
projectMembershipId: privilegeDoc.projectId as string,
|
||||
projectId: privilegeDoc.projectId as string,
|
||||
slug: privilege.name
|
||||
}
|
||||
@@ -221,7 +220,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privilege: {
|
||||
...privilege,
|
||||
identityId: privilegeDoc.actorIdentityId as string,
|
||||
projectMembershipId: privilegeDoc.projectId as string,
|
||||
projectId: privilegeDoc.projectId as string,
|
||||
slug: privilege.name
|
||||
}
|
||||
@@ -275,7 +273,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privilege: {
|
||||
...privilege,
|
||||
identityId: privilegeDoc.actorIdentityId as string,
|
||||
projectMembershipId: privilegeDoc.projectId as string,
|
||||
projectId: privilegeDoc.projectId as string,
|
||||
slug: privilege.name
|
||||
}
|
||||
@@ -338,7 +335,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privilege: {
|
||||
...privilege,
|
||||
identityId: req.query.identityId,
|
||||
projectMembershipId: privilege.projectId as string,
|
||||
projectId,
|
||||
slug: privilege.name
|
||||
}
|
||||
@@ -390,7 +386,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
privileges: privileges.map((privilege) => ({
|
||||
...privilege,
|
||||
identityId: req.query.identityId,
|
||||
projectMembershipId: privilege.projectId as string,
|
||||
projectId: req.query.projectId,
|
||||
slug: privilege.name
|
||||
}))
|
||||
|
||||
@@ -243,7 +243,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
);
|
||||
|
||||
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||
const projectPath = `/projects/secret-management/${project.id}`;
|
||||
const projectPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
@@ -399,7 +399,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
|
||||
const projectPath = `/projects/secret-management/${project.id}`;
|
||||
const projectPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
@@ -766,7 +766,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
.map((appUser) => appUser.email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
const approvalPath = `/projects/secret-management/${project.id}/approval`;
|
||||
const approvalPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}/approval`;
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
await notificationService.createUserNotifications(
|
||||
|
||||
@@ -27,6 +27,17 @@ export const getChefServerUrl = async (serverUrl?: string) => {
|
||||
return chefServerUrl;
|
||||
};
|
||||
|
||||
const buildSecureUrl = (baseUrl: string, path: string): string => {
|
||||
try {
|
||||
const url = new URL(path, baseUrl);
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid URL construction parameters"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to ensure private key is in proper PEM format
|
||||
const formatPrivateKey = (key: string): string => {
|
||||
let formattedKey = key.trim();
|
||||
@@ -138,7 +149,8 @@ export const validateChefConnectionCredentials = async (config: TChefConnectionC
|
||||
|
||||
const headers = getChefAuthHeaders("GET", path, "", inputCredentials.userName, inputCredentials.privateKey);
|
||||
|
||||
await request.get(`${hostServerUrl}${path}`, {
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
await request.get(secureUrl, {
|
||||
headers
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
@@ -168,7 +180,8 @@ export const listChefDataBags = async (appConnection: TChefConnection): Promise<
|
||||
|
||||
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
|
||||
|
||||
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
const res = await request.get<Record<string, string>>(secureUrl, {
|
||||
headers
|
||||
});
|
||||
|
||||
@@ -203,7 +216,8 @@ export const listChefDataBagItems = async (
|
||||
|
||||
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
|
||||
|
||||
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
const res = await request.get<Record<string, string>>(secureUrl, {
|
||||
headers
|
||||
});
|
||||
|
||||
@@ -238,7 +252,8 @@ export const getChefDataBagItem = async ({
|
||||
|
||||
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
|
||||
|
||||
const res = await request.get<TChefDataBagItemContent>(`${hostServerUrl}${path}`, {
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
const res = await request.get<TChefDataBagItemContent>(secureUrl, {
|
||||
headers
|
||||
});
|
||||
|
||||
@@ -255,6 +270,38 @@ export const getChefDataBagItem = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const createChefDataBagItem = async ({
|
||||
serverUrl,
|
||||
userName,
|
||||
privateKey,
|
||||
orgName,
|
||||
dataBagName,
|
||||
data
|
||||
}: Omit<TUpdateChefDataBagItem, "dataBagItemName">): Promise<void> => {
|
||||
try {
|
||||
const path = `/organizations/${orgName}/data/${dataBagName}`;
|
||||
const body = JSON.stringify(data);
|
||||
|
||||
const hostServerUrl = await getChefServerUrl(serverUrl);
|
||||
|
||||
const headers = getChefAuthHeaders("POST", path, body, userName, privateKey);
|
||||
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
await request.post(secureUrl, data, {
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to create Chef data bag item: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to create Chef data bag item"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChefDataBagItem = async ({
|
||||
serverUrl,
|
||||
userName,
|
||||
@@ -272,7 +319,8 @@ export const updateChefDataBagItem = async ({
|
||||
|
||||
const headers = getChefAuthHeaders("PUT", path, body, userName, privateKey);
|
||||
|
||||
await request.put(`${hostServerUrl}${path}`, data, {
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
await request.put(secureUrl, data, {
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -286,3 +334,35 @@ export const updateChefDataBagItem = async ({
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeChefDataBagItem = async ({
|
||||
serverUrl,
|
||||
userName,
|
||||
privateKey,
|
||||
orgName,
|
||||
dataBagName,
|
||||
dataBagItemName
|
||||
}: Omit<TUpdateChefDataBagItem, "data">): Promise<void> => {
|
||||
try {
|
||||
const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`;
|
||||
const body = "";
|
||||
|
||||
const hostServerUrl = await getChefServerUrl(serverUrl);
|
||||
|
||||
const headers = getChefAuthHeaders("DELETE", path, body, userName, privateKey);
|
||||
|
||||
const secureUrl = buildSecureUrl(hostServerUrl, path);
|
||||
await request.delete(secureUrl, {
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to remove Chef data bag item: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to remove Chef data bag item"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -186,6 +186,7 @@ export enum EventType {
|
||||
CREATE_TOKEN_IDENTITY_TOKEN_AUTH = "create-token-identity-token-auth",
|
||||
UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth",
|
||||
GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth",
|
||||
GET_TOKEN_IDENTITY_TOKEN_AUTH = "get-token-identity-token-auth",
|
||||
|
||||
ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth",
|
||||
UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth",
|
||||
@@ -387,6 +388,9 @@ export enum EventType {
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
|
||||
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
|
||||
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
|
||||
CREATE_CERTIFICATE_REQUEST = "create-certificate-request",
|
||||
GET_CERTIFICATE_REQUEST = "get-certificate-request",
|
||||
GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request",
|
||||
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
|
||||
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
|
||||
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
|
||||
@@ -535,6 +539,7 @@ export enum EventType {
|
||||
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
|
||||
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value",
|
||||
|
||||
PAM_SESSION_CREDENTIALS_GET = "pam-session-credentials-get",
|
||||
PAM_SESSION_START = "pam-session-start",
|
||||
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
|
||||
PAM_SESSION_END = "pam-session-end",
|
||||
@@ -1029,6 +1034,15 @@ interface GetTokensIdentityTokenAuthEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface GetTokenIdentityTokenAuthEvent {
|
||||
type: EventType.GET_TOKEN_IDENTITY_TOKEN_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
identityName: string;
|
||||
tokenId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddIdentityTokenAuthEvent {
|
||||
type: EventType.ADD_IDENTITY_TOKEN_AUTH;
|
||||
metadata: {
|
||||
@@ -2776,6 +2790,7 @@ interface CreateCertificateProfile {
|
||||
name: string;
|
||||
projectId: string;
|
||||
enrollmentType: string;
|
||||
issuerType: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2834,7 +2849,6 @@ interface OrderCertificateFromProfile {
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE;
|
||||
metadata: {
|
||||
certificateProfileId: string;
|
||||
orderId: string;
|
||||
profileName: string;
|
||||
};
|
||||
}
|
||||
@@ -3978,6 +3992,14 @@ interface OrgRoleDeleteEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionCredentialsGetEvent {
|
||||
type: EventType.PAM_SESSION_CREDENTIALS_GET;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
accountName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionStartEvent {
|
||||
type: EventType.PAM_SESSION_START;
|
||||
metadata: {
|
||||
@@ -4054,6 +4076,7 @@ interface PamAccountAccessEvent {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
accountPath: string;
|
||||
accountName: string;
|
||||
duration?: string;
|
||||
};
|
||||
@@ -4176,6 +4199,31 @@ interface DisableCertificateRenewalConfigEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateCertificateRequestEvent {
|
||||
type: EventType.CREATE_CERTIFICATE_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
profileId?: string;
|
||||
caId?: string;
|
||||
commonName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCertificateRequestEvent {
|
||||
type: EventType.GET_CERTIFICATE_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCertificateFromRequestEvent {
|
||||
type: EventType.GET_CERTIFICATE_FROM_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
certificateId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| CreateSubOrganizationEvent
|
||||
| UpdateSubOrganizationEvent
|
||||
@@ -4214,6 +4262,7 @@ export type Event =
|
||||
| CreateTokenIdentityTokenAuthEvent
|
||||
| UpdateTokenIdentityTokenAuthEvent
|
||||
| GetTokensIdentityTokenAuthEvent
|
||||
| GetTokenIdentityTokenAuthEvent
|
||||
| AddIdentityTokenAuthEvent
|
||||
| UpdateIdentityTokenAuthEvent
|
||||
| GetIdentityTokenAuthEvent
|
||||
@@ -4531,6 +4580,7 @@ export type Event =
|
||||
| OrgRoleCreateEvent
|
||||
| OrgRoleUpdateEvent
|
||||
| OrgRoleDeleteEvent
|
||||
| PamSessionCredentialsGetEvent
|
||||
| PamSessionStartEvent
|
||||
| PamSessionLogsUpdateEvent
|
||||
| PamSessionEndEvent
|
||||
@@ -4553,6 +4603,9 @@ export type Event =
|
||||
| PamResourceDeleteEvent
|
||||
| UpdateCertificateRenewalConfigEvent
|
||||
| DisableCertificateRenewalConfigEvent
|
||||
| CreateCertificateRequestEvent
|
||||
| GetCertificateRequestEvent
|
||||
| GetCertificateFromRequestEvent
|
||||
| AutomatedRenewCertificate
|
||||
| AutomatedRenewCertificateFailed
|
||||
| UserLoginEvent
|
||||
|
||||
+5
-2
@@ -4,7 +4,10 @@ import * as x509 from "@peculiar/x509";
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import {
|
||||
ProjectPermissionCertificateAuthorityActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||
import { expandInternalCa } from "@app/services/certificate-authority/certificate-authority-fns";
|
||||
@@ -83,7 +86,7 @@ export const certificateAuthorityCrlServiceFactory = ({
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionCertificateAuthorityActions.Read,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||
import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { applyJitter } from "@app/lib/delay";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
|
||||
import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types";
|
||||
@@ -15,7 +23,12 @@ import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types";
|
||||
type TDynamicSecretLeaseQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "findById" | "deleteById" | "find" | "updateById">;
|
||||
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
identityDAL: TIdentityDALFactory;
|
||||
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById" | "findOne">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findById">;
|
||||
@@ -23,18 +36,24 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = {
|
||||
|
||||
export type TDynamicSecretLeaseQueueServiceFactory = {
|
||||
pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise<void>;
|
||||
setLeaseRevocation: (leaseId: string, expiryAt: Date) => Promise<void>;
|
||||
setLeaseRevocation: (leaseId: string, dynamicSecretId: string, expiryAt: Date) => Promise<void>;
|
||||
unsetLeaseRevocation: (leaseId: string) => Promise<void>;
|
||||
queueFailedRevocation: (leaseId: string, dynamicSecretId: string) => Promise<void>;
|
||||
init: () => Promise<void>;
|
||||
};
|
||||
|
||||
const MAX_REVOCATION_RETRY_COUNT = 10;
|
||||
|
||||
export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
queueService,
|
||||
dynamicSecretDAL,
|
||||
dynamicSecretProviders,
|
||||
dynamicSecretLeaseDAL,
|
||||
kmsService,
|
||||
folderDAL
|
||||
folderDAL,
|
||||
projectMembershipDAL,
|
||||
projectDAL,
|
||||
smtpService
|
||||
}: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => {
|
||||
const pruneDynamicSecret = async (dynamicSecretCfgId: string) => {
|
||||
await queueService.queuePg<QueueName.DynamicSecretRevocation>(
|
||||
@@ -48,10 +67,10 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
);
|
||||
};
|
||||
|
||||
const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => {
|
||||
const setLeaseRevocation = async (leaseId: string, dynamicSecretId: string, expiryAt: Date) => {
|
||||
await queueService.queuePg<QueueName.DynamicSecretRevocation>(
|
||||
QueueJobs.DynamicSecretRevocation,
|
||||
{ leaseId },
|
||||
{ leaseId, dynamicSecretId },
|
||||
{
|
||||
id: leaseId,
|
||||
singletonKey: leaseId,
|
||||
@@ -68,10 +87,53 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId);
|
||||
};
|
||||
|
||||
const queueFailedRevocation = async (leaseId: string, dynamicSecretId: string) => {
|
||||
const appConfig = getConfig();
|
||||
|
||||
const retryDelaySeconds = appConfig.isDevelopmentMode ? 1 : Math.floor(applyJitter(3_600_000 * 4) / 1000); // retry every 4 hours with 20% +- jitter (convert ms to seconds for pgboss)
|
||||
|
||||
await queueService.queuePg<QueueName.DynamicSecretRevocation>(
|
||||
QueueJobs.DynamicSecretRevocation,
|
||||
{ leaseId, isRetry: true, dynamicSecretId },
|
||||
{
|
||||
singletonKey: `${leaseId}-retry`, // avoid conflicts with scheduled revocation
|
||||
retryDelay: retryDelaySeconds,
|
||||
retryLimit: MAX_REVOCATION_RETRY_COUNT, // we dont want it to ever hit the limit, we want the expireInHours to take effect.
|
||||
expireInHours: 23 // if we set it to 24 hours, pgboss will complain that the expireIn is too high
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const $queueDynamicSecretLeaseRevocationFailedEmail = async (leaseId: string, dynamicSecretId: string) => {
|
||||
const appConfig = getConfig();
|
||||
|
||||
const delay = appConfig.isDevelopmentMode ? 1_000 * 60 : 1_000 * 60 * 15; // 1 minute in development, 15 minutes in production
|
||||
|
||||
await queueService.queue(
|
||||
QueueName.DynamicSecretLeaseRevocationFailedEmail,
|
||||
QueueJobs.DynamicSecretLeaseRevocationFailedEmail,
|
||||
{
|
||||
leaseId
|
||||
},
|
||||
{
|
||||
jobId: `dynamic-secret-lease-revocation-failed-email-${dynamicSecretId}`,
|
||||
delay,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000 * 60 // 1 minute
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const $dynamicSecretQueueJob = async (
|
||||
jobName: string,
|
||||
jobId: string,
|
||||
data: { leaseId: string } | { dynamicSecretCfgId: string }
|
||||
data: { leaseId: string; dynamicSecretId: string; isRetry?: boolean } | { dynamicSecretCfgId: string },
|
||||
retryCount?: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (jobName === QueueJobs.DynamicSecretRevocation) {
|
||||
@@ -79,7 +141,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
logger.info("Dynamic secret lease revocation started: ", leaseId, jobId);
|
||||
|
||||
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||
if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
|
||||
if (!dynamicSecretLease) {
|
||||
throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findById(dynamicSecretLease.dynamicSecret.folderId);
|
||||
if (!folder)
|
||||
@@ -150,7 +214,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
}
|
||||
logger.info("Finished dynamic secret job", jobId);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(error, "Failed to delete dynamic secret");
|
||||
|
||||
if (jobName === QueueJobs.DynamicSecretPruning) {
|
||||
const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string };
|
||||
@@ -161,20 +225,97 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
}
|
||||
|
||||
if (jobName === QueueJobs.DynamicSecretRevocation) {
|
||||
const { leaseId } = data as { leaseId: string };
|
||||
const { leaseId, isRetry, dynamicSecretId } = data as {
|
||||
leaseId: string;
|
||||
isRetry?: boolean;
|
||||
dynamicSecretId: string;
|
||||
};
|
||||
await dynamicSecretLeaseDAL.updateById(leaseId, {
|
||||
status: DynamicSecretStatus.FailedDeletion,
|
||||
statusDetails: (error as Error)?.message?.slice(0, 255)
|
||||
statusDetails: `${(error as Error)?.message?.slice(0, 255)} - Retrying automatically`
|
||||
});
|
||||
|
||||
// only add to retry queue if this is not a retry, and if the error is not a DisableRotationErrors error
|
||||
if (!isRetry && !(error instanceof DisableRotationErrors)) {
|
||||
// if revocation fails, we should stop the job and queue a new job to retry the revocation at a later time.
|
||||
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
|
||||
await queueFailedRevocation(leaseId, dynamicSecretId);
|
||||
|
||||
// if its the last attempt, and the error isn't a DisableRotationErrors error, send an email to the project admins (debounced)
|
||||
} else if (isRetry && !(error instanceof DisableRotationErrors)) {
|
||||
if (retryCount && retryCount === MAX_REVOCATION_RETRY_COUNT) {
|
||||
// if all retries fail, we should also stop the automatic revocation job.
|
||||
// the ID of the revocation job is set to the leaseId, so we can use that to stop the job
|
||||
|
||||
// we dont have to stop the retry job, because if we hit this point, its the last attempt and the retry job will be stopped by pgboss itself after this point,
|
||||
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId);
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, leaseId);
|
||||
|
||||
await $queueDynamicSecretLeaseRevocationFailedEmail(leaseId, dynamicSecretId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error instanceof DisableRotationErrors) {
|
||||
if (jobId) {
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
|
||||
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
|
||||
}
|
||||
} else {
|
||||
// propagate to next part
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// send alert email once all revocation attempts have failed
|
||||
const $dynamicSecretLeaseRevocationFailedEmailJob = async (jobId: string, data: { leaseId: string }) => {
|
||||
try {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const { leaseId } = data;
|
||||
logger.info(
|
||||
{ leaseId, jobId },
|
||||
"Dynamic secret revocation failed. Notifying project admins about failed revocation."
|
||||
);
|
||||
|
||||
const lease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||
if (!lease) {
|
||||
throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findById(lease.dynamicSecret.folderId);
|
||||
if (!folder) throw new NotFoundError({ message: `Failed to find folder with ${lease.dynamicSecret.folderId}` });
|
||||
|
||||
const project = await projectDAL.findById(folder.projectId);
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(project.id);
|
||||
|
||||
const projectAdmins = projectMembers.filter((member) =>
|
||||
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
|
||||
);
|
||||
|
||||
await smtpService.sendMail({
|
||||
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
|
||||
template: SmtpTemplates.DynamicSecretLeaseRevocationFailed,
|
||||
subjectLine: "Dynamic Secret Lease Revocation Failed",
|
||||
substitutions: {
|
||||
dynamicSecretLeaseUrl: `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/secrets/${folder.environment.envSlug}?dynamicSecretId=${lease.dynamicSecret.id}&filterBy=dynamic&search=${lease.dynamicSecret.name}`,
|
||||
dynamicSecretName: lease.dynamicSecret.name,
|
||||
projectName: project.name,
|
||||
environmentSlug: folder.environment.envSlug,
|
||||
errorMessage: lease.statusDetails || "An unknown error occurred"
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to send dynamic secret lease revocation failed email");
|
||||
if (error instanceof DisableRotationErrors) {
|
||||
if (jobId) {
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId);
|
||||
await queueService.stopJobById(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
// propogate to next part
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,14 +323,21 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
await $dynamicSecretQueueJob(job.name, job.id as string, job.data);
|
||||
});
|
||||
|
||||
// we use redis for sending the email because:
|
||||
// 1. we are insensitive to losing the jobs in queue in case of a disaster event
|
||||
// 2. pgboss does not support exclusive job keys on v0.10.x, and upgrading to v0.11.x which supports exclusive jobs comes with a lot of breaking changes, and we would need to manually migrate our existing jobs to the new version
|
||||
queueService.start(QueueName.DynamicSecretLeaseRevocationFailedEmail, async (job) => {
|
||||
await $dynamicSecretLeaseRevocationFailedEmailJob(job.id as string, job.data);
|
||||
});
|
||||
|
||||
const init = async () => {
|
||||
await queueService.startPg<QueueName.DynamicSecretRevocation>(
|
||||
QueueJobs.DynamicSecretRevocation,
|
||||
async ([job]) => {
|
||||
await $dynamicSecretQueueJob(job.name, job.id, job.data);
|
||||
await $dynamicSecretQueueJob(job.name, job.id, job.data, job.retryCount);
|
||||
},
|
||||
{
|
||||
workerCount: 5,
|
||||
workerCount: 10,
|
||||
pollingIntervalSeconds: 1
|
||||
}
|
||||
);
|
||||
@@ -210,6 +358,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
pruneDynamicSecret,
|
||||
setLeaseRevocation,
|
||||
unsetLeaseRevocation,
|
||||
queueFailedRevocation,
|
||||
init
|
||||
};
|
||||
};
|
||||
|
||||
@@ -178,7 +178,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
config
|
||||
});
|
||||
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt);
|
||||
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
|
||||
};
|
||||
|
||||
@@ -272,7 +272,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
);
|
||||
|
||||
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt);
|
||||
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||
expireAt,
|
||||
externalEntityId: entityId
|
||||
@@ -358,11 +358,13 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
if ((revokeResponse as { error?: Error })?.error) {
|
||||
const { error } = revokeResponse as { error?: Error };
|
||||
logger.error(error?.message, "Failed to revoke lease");
|
||||
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||
status: DynamicSecretLeaseStatus.FailedDeletion,
|
||||
statusDetails: error?.message?.slice(0, 255)
|
||||
});
|
||||
return deletedDynamicSecretLease;
|
||||
// queue a job to retry the revocation at a later time
|
||||
await dynamicSecretQueueService.queueFailedRevocation(dynamicSecretLease.id, dynamicSecretCfg.id);
|
||||
return updatedDynamicSecretLease;
|
||||
}
|
||||
|
||||
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
} from "./external-kms-types";
|
||||
import { AwsKmsProviderFactory } from "./providers/aws-kms";
|
||||
import { GcpKmsProviderFactory } from "./providers/gcp-kms";
|
||||
import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model";
|
||||
import {
|
||||
ExternalKmsAwsSchema,
|
||||
ExternalKmsGcpSchema,
|
||||
KmsProviders,
|
||||
TExternalKmsAwsSchema,
|
||||
TExternalKmsGcpSchema
|
||||
} from "./providers/model";
|
||||
|
||||
type TExternalKmsServiceFactoryDep = {
|
||||
externalKmsDAL: TExternalKmsDALFactory;
|
||||
@@ -72,6 +78,7 @@ export const externalKmsServiceFactory = ({
|
||||
const kmsName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase());
|
||||
|
||||
let sanitizedProviderInput = "";
|
||||
let sanitizedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema;
|
||||
switch (provider.type) {
|
||||
case KmsProviders.Aws:
|
||||
{
|
||||
@@ -88,9 +95,18 @@ export const externalKmsServiceFactory = ({
|
||||
try {
|
||||
// if missing kms key this generate a new kms key id and returns new provider input
|
||||
const newProviderInput = await externalKms.generateInputKmsKey();
|
||||
sanitizedProviderInputObject = newProviderInput;
|
||||
sanitizedProviderInput = JSON.stringify(newProviderInput);
|
||||
|
||||
await externalKms.validateConnection();
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: error instanceof Error ? `AWS error: ${error.message}` : "Failed to validate AWS connection"
|
||||
});
|
||||
} finally {
|
||||
await externalKms.cleanup();
|
||||
}
|
||||
@@ -101,7 +117,16 @@ export const externalKmsServiceFactory = ({
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs });
|
||||
try {
|
||||
await externalKms.validateConnection();
|
||||
sanitizedProviderInputObject = provider.inputs;
|
||||
sanitizedProviderInput = JSON.stringify(provider.inputs);
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to validate GCP connection"
|
||||
});
|
||||
} finally {
|
||||
await externalKms.cleanup();
|
||||
}
|
||||
@@ -139,7 +164,10 @@ export const externalKmsServiceFactory = ({
|
||||
},
|
||||
tx
|
||||
);
|
||||
return { ...kms, external: externalKmsCfg };
|
||||
return {
|
||||
...kms,
|
||||
external: { ...externalKmsCfg, providerInput: sanitizedProviderInputObject }
|
||||
};
|
||||
});
|
||||
|
||||
return externalKms;
|
||||
@@ -179,6 +207,7 @@ export const externalKmsServiceFactory = ({
|
||||
if (!externalKmsDoc) throw new NotFoundError({ message: `External KMS with ID '${kmsId}' not found` });
|
||||
|
||||
let sanitizedProviderInput = "";
|
||||
let sanitizedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema;
|
||||
const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } =
|
||||
await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
@@ -199,7 +228,16 @@ export const externalKmsServiceFactory = ({
|
||||
const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput });
|
||||
try {
|
||||
await externalKms.validateConnection();
|
||||
sanitizedProviderInputObject = updatedProviderInput;
|
||||
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: error instanceof Error ? `AWS error: ${error.message}` : "Failed to validate AWS connection"
|
||||
});
|
||||
} finally {
|
||||
await externalKms.cleanup();
|
||||
}
|
||||
@@ -214,7 +252,16 @@ export const externalKmsServiceFactory = ({
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput });
|
||||
try {
|
||||
await externalKms.validateConnection();
|
||||
sanitizedProviderInputObject = updatedProviderInput;
|
||||
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to validate GCP connection"
|
||||
});
|
||||
} finally {
|
||||
await externalKms.cleanup();
|
||||
}
|
||||
@@ -234,14 +281,17 @@ export const externalKmsServiceFactory = ({
|
||||
}
|
||||
|
||||
const externalKms = await externalKmsDAL.transaction(async (tx) => {
|
||||
const kms = await kmsDAL.updateById(
|
||||
kmsDoc.id,
|
||||
{
|
||||
description,
|
||||
name: kmsName
|
||||
},
|
||||
tx
|
||||
);
|
||||
let kms = kmsDoc;
|
||||
if (kmsName || description) {
|
||||
kms = await kmsDAL.updateById(
|
||||
kmsDoc.id,
|
||||
{
|
||||
description,
|
||||
name: kmsName
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
if (encryptedProviderInputs) {
|
||||
const externalKmsCfg = await externalKmsDAL.updateById(
|
||||
externalKmsDoc.id,
|
||||
@@ -250,9 +300,9 @@ export const externalKmsServiceFactory = ({
|
||||
},
|
||||
tx
|
||||
);
|
||||
return { ...kms, external: externalKmsCfg };
|
||||
return { ...kms, external: { ...externalKmsCfg, providerInput: sanitizedProviderInputObject } };
|
||||
}
|
||||
return { ...kms, external: externalKmsDoc };
|
||||
return { ...kms, external: { ...externalKmsDoc, providerInput: sanitizedProviderInputObject } };
|
||||
});
|
||||
|
||||
return externalKms;
|
||||
@@ -273,9 +323,40 @@ export const externalKmsServiceFactory = ({
|
||||
const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id });
|
||||
if (!externalKmsDoc) throw new NotFoundError({ message: `External KMS with ID '${kmsId}' not found` });
|
||||
|
||||
let decryptedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema;
|
||||
|
||||
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
const decryptedProviderInputBlob = orgDataKeyDecryptor({
|
||||
cipherTextBlob: externalKmsDoc.encryptedProviderInputs
|
||||
});
|
||||
|
||||
switch (externalKmsDoc.provider) {
|
||||
case KmsProviders.Aws: {
|
||||
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
decryptedProviderInputObject = decryptedProviderInput;
|
||||
break;
|
||||
}
|
||||
case KmsProviders.Gcp: {
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
|
||||
decryptedProviderInputObject = decryptedProviderInput;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const externalKms = await externalKmsDAL.transaction(async (tx) => {
|
||||
const kms = await kmsDAL.deleteById(kmsDoc.id, tx);
|
||||
return { ...kms, external: externalKmsDoc };
|
||||
return { ...kms, external: { ...externalKmsDoc, providerInput: decryptedProviderInputObject } };
|
||||
});
|
||||
|
||||
return externalKms;
|
||||
@@ -393,6 +474,14 @@ export const externalKmsServiceFactory = ({
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } });
|
||||
try {
|
||||
return await externalKms.getKeysList();
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to fetch GCP keys"
|
||||
});
|
||||
} finally {
|
||||
await externalKms.cleanup();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
|
||||
|
||||
import { CustomAWSHasher } from "@app/lib/aws/hashing";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { ExternalKmsAwsSchema, KmsAwsCredentialType, TExternalKmsAwsSchema, TExternalKmsProviderFns } from "./model";
|
||||
|
||||
@@ -22,7 +23,7 @@ const getAwsKmsClient = async (providerInputs: TExternalKmsAwsSchema) => {
|
||||
});
|
||||
const response = await stsClient.send(command);
|
||||
if (!response.Credentials?.AccessKeyId || !response.Credentials?.SecretAccessKey)
|
||||
throw new Error("Failed to assume role");
|
||||
throw new BadRequestError({ message: "Failed to assume role" });
|
||||
|
||||
const kmsClient = new KMSClient({
|
||||
region: providerInputs.awsRegion,
|
||||
@@ -67,7 +68,7 @@ export const AwsKmsProviderFactory = async ({ inputs }: AwsKmsProviderArgs): Pro
|
||||
const command = new CreateKeyCommand({ Tags: [{ TagKey: "author", TagValue: "infisical" }] });
|
||||
const kmsKey = await awsClient.send(command);
|
||||
|
||||
if (!kmsKey.KeyMetadata?.KeyId) throw new Error("Failed to generate kms key");
|
||||
if (!kmsKey.KeyMetadata?.KeyId) throw new BadRequestError({ message: "Failed to generate kms key" });
|
||||
|
||||
const updatedProviderInputs = await ExternalKmsAwsSchema.parseAsync({
|
||||
...providerInputs,
|
||||
|
||||
@@ -19,27 +19,31 @@ export enum KmsGcpKeyFetchAuthType {
|
||||
Kms = "kmsId"
|
||||
}
|
||||
|
||||
const AwsConnectionAssumeRoleCredentialsSchema = z.object({
|
||||
assumeRoleArn: z.string().trim().min(1).describe("AWS user role to be assumed by infisical"),
|
||||
externalId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("AWS assume role external id for further security in authentication")
|
||||
});
|
||||
|
||||
const AwsConnectionAccessTokenCredentialsSchema = z.object({
|
||||
accessKey: z.string().trim().min(1).describe("AWS user account access key"),
|
||||
secretKey: z.string().trim().min(1).describe("AWS user account secret key")
|
||||
});
|
||||
|
||||
export const ExternalKmsAwsSchema = z.object({
|
||||
credential: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(KmsAwsCredentialType.AccessKey),
|
||||
data: z.object({
|
||||
accessKey: z.string().trim().min(1).describe("AWS user account access key"),
|
||||
secretKey: z.string().trim().min(1).describe("AWS user account secret key")
|
||||
})
|
||||
data: AwsConnectionAccessTokenCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(KmsAwsCredentialType.AssumeRole),
|
||||
data: z.object({
|
||||
assumeRoleArn: z.string().trim().min(1).describe("AWS user role to be assumed by infisical"),
|
||||
externalId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("AWS assume role external id for furthur security in authentication")
|
||||
})
|
||||
data: AwsConnectionAssumeRoleCredentialsSchema
|
||||
})
|
||||
])
|
||||
.describe("AWS credential information to connect"),
|
||||
@@ -52,6 +56,22 @@ export const ExternalKmsAwsSchema = z.object({
|
||||
});
|
||||
export type TExternalKmsAwsSchema = z.infer<typeof ExternalKmsAwsSchema>;
|
||||
|
||||
export const SanitizedExternalKmsAwsSchema = ExternalKmsAwsSchema.extend({
|
||||
credential: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(KmsAwsCredentialType.AccessKey),
|
||||
data: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKey: true })
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(KmsAwsCredentialType.AssumeRole),
|
||||
data: AwsConnectionAssumeRoleCredentialsSchema.pick({
|
||||
assumeRoleArn: true,
|
||||
externalId: true
|
||||
})
|
||||
})
|
||||
])
|
||||
});
|
||||
|
||||
export const ExternalKmsGcpCredentialSchema = z.object({
|
||||
type: z.literal(KmsGcpCredentialType.ServiceAccount),
|
||||
project_id: z.string().min(1),
|
||||
@@ -75,6 +95,8 @@ export const ExternalKmsGcpSchema = z.object({
|
||||
});
|
||||
export type TExternalKmsGcpSchema = z.infer<typeof ExternalKmsGcpSchema>;
|
||||
|
||||
export const SanitizedExternalKmsGcpSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true });
|
||||
|
||||
const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({
|
||||
credential: ExternalKmsGcpCredentialSchema
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import { TDbClient } from "@app/db";
|
||||
import { AccessScope, TableName, TGroups } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { EFilterReturnedUsers } from "./group-types";
|
||||
import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types";
|
||||
|
||||
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
|
||||
|
||||
@@ -166,6 +167,89 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findAllGroupProjects = async ({
|
||||
orgId,
|
||||
groupId,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
filter,
|
||||
orderBy,
|
||||
orderDirection
|
||||
}: {
|
||||
orgId: string;
|
||||
groupId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedProjects;
|
||||
orderBy?: EGroupProjectsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
}) => {
|
||||
try {
|
||||
const query = db
|
||||
.replicaNode()(TableName.Project)
|
||||
.where(`${TableName.Project}.orgId`, orgId)
|
||||
.leftJoin(TableName.Membership, (bd) => {
|
||||
bd.on(`${TableName.Project}.id`, "=", `${TableName.Membership}.scopeProjectId`)
|
||||
.andOn(`${TableName.Membership}.actorGroupId`, "=", db.raw("?", [groupId]))
|
||||
.andOn(`${TableName.Membership}.scope`, "=", db.raw("?", [AccessScope.Project]));
|
||||
})
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.Project),
|
||||
db.ref("name").withSchema(TableName.Project),
|
||||
db.ref("slug").withSchema(TableName.Project),
|
||||
db.ref("description").withSchema(TableName.Project),
|
||||
db.ref("type").withSchema(TableName.Project),
|
||||
db.ref("createdAt").withSchema(TableName.Membership).as("joinedGroupAt"),
|
||||
db.raw(`count(*) OVER() as "totalCount"`)
|
||||
)
|
||||
.offset(offset ?? 0);
|
||||
|
||||
if (orderBy) {
|
||||
void query.orderByRaw(
|
||||
`LOWER(${TableName.Project}.??) ${orderDirection === OrderByDirection.ASC ? "asc" : "desc"}`,
|
||||
[orderBy]
|
||||
);
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
void query.limit(limit);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
void query.andWhereRaw(
|
||||
`CONCAT_WS(' ', "${TableName.Project}"."name", "${TableName.Project}"."slug", "${TableName.Project}"."description") ilike ?`,
|
||||
[`%${search}%`]
|
||||
);
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case EFilterReturnedProjects.ASSIGNED_PROJECTS:
|
||||
void query.whereNotNull(`${TableName.Membership}.id`);
|
||||
break;
|
||||
case EFilterReturnedProjects.UNASSIGNED_PROJECTS:
|
||||
void query.whereNull(`${TableName.Membership}.id`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const projects = await query;
|
||||
|
||||
return {
|
||||
projects: projects.map(({ joinedGroupAt, ...project }) => ({
|
||||
...project,
|
||||
joinedGroupAt
|
||||
})),
|
||||
// @ts-expect-error col select is raw and not strongly typed
|
||||
totalCount: Number(projects?.[0]?.totalCount ?? 0)
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find all group projects" });
|
||||
}
|
||||
};
|
||||
|
||||
const findGroupsByProjectId = async (projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.Groups)
|
||||
@@ -230,6 +314,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
findGroups,
|
||||
findByOrgId,
|
||||
findAllGroupPossibleMembers,
|
||||
findAllGroupProjects,
|
||||
findGroupsByProjectId,
|
||||
findById,
|
||||
findOne
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TCreateGroupDTO,
|
||||
TDeleteGroupDTO,
|
||||
TGetGroupByIdDTO,
|
||||
TListGroupProjectsDTO,
|
||||
TListGroupUsersDTO,
|
||||
TRemoveUserFromGroupDTO,
|
||||
TUpdateGroupDTO
|
||||
@@ -34,7 +35,14 @@ type TGroupServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
|
||||
groupDAL: Pick<
|
||||
TGroupDALFactory,
|
||||
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
|
||||
| "create"
|
||||
| "findOne"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "findAllGroupPossibleMembers"
|
||||
| "findById"
|
||||
| "transaction"
|
||||
| "findAllGroupProjects"
|
||||
>;
|
||||
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
|
||||
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
|
||||
@@ -367,6 +375,55 @@ export const groupServiceFactory = ({
|
||||
return { users: members, totalCount };
|
||||
};
|
||||
|
||||
const listGroupProjects = async ({
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
filter,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TListGroupProjectsDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor,
|
||||
actorId,
|
||||
orgId: actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new NotFoundError({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { projects, totalCount } = await groupDAL.findAllGroupProjects({
|
||||
orgId: group.orgId,
|
||||
groupId: group.id,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
filter,
|
||||
orderBy,
|
||||
orderDirection
|
||||
});
|
||||
|
||||
return { projects, totalCount };
|
||||
};
|
||||
|
||||
const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
@@ -535,6 +592,7 @@ export const groupServiceFactory = ({
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
listGroupUsers,
|
||||
listGroupProjects,
|
||||
addUserToGroup,
|
||||
removeUserFromGroup,
|
||||
getGroupById
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Knex } from "knex";
|
||||
|
||||
import { TGroups } from "@app/db/schemas";
|
||||
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||
import { TGenericPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TGenericPermission } from "@app/lib/types";
|
||||
import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
@@ -42,6 +42,16 @@ export type TListGroupUsersDTO = {
|
||||
filter?: EFilterReturnedUsers;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListGroupProjectsDTO = {
|
||||
id: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedProjects;
|
||||
orderBy?: EGroupProjectsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListProjectGroupUsersDTO = TListGroupUsersDTO & {
|
||||
projectId: string;
|
||||
};
|
||||
@@ -111,3 +121,12 @@ export enum EFilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
||||
export enum EFilterReturnedProjects {
|
||||
ASSIGNED_PROJECTS = "assignedProjects",
|
||||
UNASSIGNED_PROJECTS = "unassignedProjects"
|
||||
}
|
||||
|
||||
export enum EGroupProjectsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
@@ -39,3 +39,9 @@ export const getDefaultOnPremFeatures = () => {
|
||||
};
|
||||
|
||||
export const setupLicenseRequestWithStore = () => {};
|
||||
|
||||
export const getLicenseKeyConfig = () => {
|
||||
return {
|
||||
isValid: false
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { getConfig, TEnvConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
|
||||
|
||||
import { TFeatureSet } from "./license-types";
|
||||
import { LicenseType, TFeatureSet, TLicenseKeyConfig, TOfflineLicenseContents } from "./license-types";
|
||||
|
||||
export const isOfflineLicenseKey = (licenseKey: string): boolean => {
|
||||
try {
|
||||
const contents = JSON.parse(Buffer.from(licenseKey, "base64").toString("utf8")) as TOfflineLicenseContents;
|
||||
|
||||
return "signature" in contents && "license" in contents;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLicenseKeyConfig = (
|
||||
config?: Pick<TEnvConfig, "LICENSE_KEY" | "LICENSE_KEY_OFFLINE">
|
||||
): TLicenseKeyConfig => {
|
||||
const cfg = config || getConfig();
|
||||
|
||||
if (!cfg) {
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
const licenseKey = cfg.LICENSE_KEY;
|
||||
|
||||
if (licenseKey) {
|
||||
if (isOfflineLicenseKey(licenseKey)) {
|
||||
return { isValid: true, licenseKey, type: LicenseType.Offline };
|
||||
}
|
||||
|
||||
return { isValid: true, licenseKey, type: LicenseType.Online };
|
||||
}
|
||||
|
||||
const offlineLicenseKey = cfg.LICENSE_KEY_OFFLINE;
|
||||
|
||||
// backwards compatibility
|
||||
if (offlineLicenseKey) {
|
||||
if (isOfflineLicenseKey(offlineLicenseKey)) {
|
||||
return { isValid: true, licenseKey: offlineLicenseKey, type: LicenseType.Offline };
|
||||
}
|
||||
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
return { isValid: false };
|
||||
};
|
||||
|
||||
export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
_id: null,
|
||||
|
||||
@@ -22,9 +22,10 @@ import { OrgPermissionBillingActions, OrgPermissionSubjects } from "../permissio
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||
import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums";
|
||||
import { TLicenseDALFactory } from "./license-dal";
|
||||
import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns";
|
||||
import { getDefaultOnPremFeatures, getLicenseKeyConfig, setupLicenseRequestWithStore } from "./license-fns";
|
||||
import {
|
||||
InstanceType,
|
||||
LicenseType,
|
||||
TAddOrgPmtMethodDTO,
|
||||
TAddOrgTaxIdDTO,
|
||||
TCreateOrgPortalSession,
|
||||
@@ -77,6 +78,7 @@ export const licenseServiceFactory = ({
|
||||
let instanceType = InstanceType.OnPrem;
|
||||
let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures();
|
||||
let selfHostedLicense: TOfflineLicense | null = null;
|
||||
const licenseKeyConfig = getLicenseKeyConfig(envConfig);
|
||||
|
||||
const licenseServerCloudApi = setupLicenseRequestWithStore(
|
||||
envConfig.LICENSE_SERVER_URL || "",
|
||||
@@ -85,10 +87,13 @@ export const licenseServiceFactory = ({
|
||||
envConfig.INTERNAL_REGION
|
||||
);
|
||||
|
||||
const onlineLicenseKey =
|
||||
licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Online ? licenseKeyConfig.licenseKey : "";
|
||||
|
||||
const licenseServerOnPremApi = setupLicenseRequestWithStore(
|
||||
envConfig.LICENSE_SERVER_URL || "",
|
||||
LICENSE_SERVER_ON_PREM_LOGIN,
|
||||
envConfig.LICENSE_KEY || "",
|
||||
onlineLicenseKey,
|
||||
envConfig.INTERNAL_REGION
|
||||
);
|
||||
|
||||
@@ -131,7 +136,7 @@ export const licenseServiceFactory = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (envConfig.LICENSE_KEY) {
|
||||
if (licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Online) {
|
||||
const token = await licenseServerOnPremApi.refreshLicense();
|
||||
if (token) {
|
||||
await syncLicenseKeyOnPremFeatures(true);
|
||||
@@ -142,10 +147,10 @@ export const licenseServiceFactory = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (envConfig.LICENSE_KEY_OFFLINE) {
|
||||
if (licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Offline) {
|
||||
let isValidOfflineLicense = true;
|
||||
const contents: TOfflineLicenseContents = JSON.parse(
|
||||
Buffer.from(envConfig.LICENSE_KEY_OFFLINE, "base64").toString("utf8")
|
||||
Buffer.from(licenseKeyConfig.licenseKey, "base64").toString("utf8")
|
||||
);
|
||||
const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature);
|
||||
|
||||
@@ -184,7 +189,7 @@ export const licenseServiceFactory = ({
|
||||
};
|
||||
|
||||
const initializeBackgroundSync = async () => {
|
||||
if (envConfig.LICENSE_KEY) {
|
||||
if (licenseKeyConfig?.isValid && licenseKeyConfig?.type === LicenseType.Online) {
|
||||
logger.info("Setting up background sync process for refresh onPremFeatures");
|
||||
const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures);
|
||||
job.start();
|
||||
@@ -445,8 +450,8 @@ export const licenseServiceFactory = ({
|
||||
} = await licenseServerCloudApi.request.post(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`,
|
||||
{
|
||||
success_url: `${envConfig.SITE_URL}/organization/billing`,
|
||||
cancel_url: `${envConfig.SITE_URL}/organization/billing`
|
||||
success_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing`,
|
||||
cancel_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing`
|
||||
}
|
||||
);
|
||||
|
||||
@@ -459,7 +464,7 @@ export const licenseServiceFactory = ({
|
||||
} = await licenseServerCloudApi.request.post(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`,
|
||||
{
|
||||
return_url: `${envConfig.SITE_URL}/organization/billing`
|
||||
return_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing`
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -136,3 +136,18 @@ export type TDelOrgTaxIdDTO = TOrgPermission & { taxId: string };
|
||||
export type TOrgInvoiceDTO = TOrgPermission;
|
||||
|
||||
export type TOrgLicensesDTO = TOrgPermission;
|
||||
|
||||
export enum LicenseType {
|
||||
Offline = "offline",
|
||||
Online = "online"
|
||||
}
|
||||
|
||||
export type TLicenseKeyConfig =
|
||||
| {
|
||||
isValid: false;
|
||||
}
|
||||
| {
|
||||
isValid: true;
|
||||
licenseKey: string;
|
||||
type: LicenseType;
|
||||
};
|
||||
|
||||
@@ -1,46 +1,109 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPamAccounts } from "@app/db/schemas";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||
|
||||
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
|
||||
|
||||
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
|
||||
|
||||
export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamAccount);
|
||||
|
||||
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PamAccount)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.select(selectAllTableCols(TableName.PamAccount))
|
||||
.select(
|
||||
const findByProjectIdWithResourceDetails = async (
|
||||
{
|
||||
projectId,
|
||||
folderId,
|
||||
accountView = PamAccountView.Nested,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = PamAccountOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
filterResourceIds
|
||||
}: {
|
||||
projectId: string;
|
||||
folderId?: string | null;
|
||||
accountView?: PamAccountView;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: PamAccountOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
filterResourceIds?: string[];
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const dbInstance = tx || db.replicaNode();
|
||||
const query = dbInstance(TableName.PamAccount)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.where(`${TableName.PamAccount}.projectId`, projectId);
|
||||
|
||||
if (accountView === PamAccountView.Nested) {
|
||||
if (folderId) {
|
||||
void query.where(`${TableName.PamAccount}.folderId`, folderId);
|
||||
} else {
|
||||
void query.whereNull(`${TableName.PamAccount}.folderId`);
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
const pattern = `%${escapedSearch}%`;
|
||||
void query.where((q) => {
|
||||
void q
|
||||
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "name", pattern])
|
||||
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
|
||||
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "description", pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
if (filterResourceIds && filterResourceIds.length) {
|
||||
void query.whereIn(`${TableName.PamAccount}.resourceId`, filterResourceIds);
|
||||
}
|
||||
|
||||
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||
|
||||
void query.select(selectAllTableCols(TableName.PamAccount)).select(
|
||||
// resource
|
||||
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
||||
db.ref("resourceType").withSchema(TableName.PamResource),
|
||||
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
|
||||
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||
|
||||
void query.orderByRaw(`${TableName.PamAccount}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||
|
||||
if (typeof limit === "number") {
|
||||
void query.limit(limit).offset(offset);
|
||||
}
|
||||
|
||||
const [results, countResult] = await Promise.all([query, countQuery]);
|
||||
const totalCount = Number(countResult?.count || 0);
|
||||
|
||||
const accounts = results.map(
|
||||
// @ts-expect-error resourceName, resourceType, encryptedRotationAccountCredentials are from joined table
|
||||
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
|
||||
...account,
|
||||
resourceId,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
name: resourceName as string,
|
||||
resourceType,
|
||||
encryptedRotationAccountCredentials
|
||||
}
|
||||
})
|
||||
);
|
||||
return { accounts, totalCount };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find PAM accounts with resource details" });
|
||||
}
|
||||
|
||||
const accounts = await query;
|
||||
|
||||
return accounts.map(
|
||||
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
|
||||
...account,
|
||||
resourceId,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
name: resourceName,
|
||||
resourceType,
|
||||
encryptedRotationAccountCredentials
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const findAccountsDueForRotation = async (tx?: Knex) => {
|
||||
@@ -59,5 +122,9 @@ export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
return accounts;
|
||||
};
|
||||
|
||||
return { ...orm, findWithResourceDetails, findAccountsDueForRotation };
|
||||
return {
|
||||
...orm,
|
||||
findByProjectIdWithResourceDetails,
|
||||
findAccountsDueForRotation
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum PamAccountOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum PamAccountView {
|
||||
Flat = "flat",
|
||||
Nested = "nested"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas";
|
||||
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas";
|
||||
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
|
||||
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
@@ -27,12 +27,14 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
||||
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||
import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
|
||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPamAccountDALFactory } from "./pam-account-dal";
|
||||
import { PamAccountView } from "./pam-account-enums";
|
||||
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
|
||||
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types";
|
||||
import { TAccessAccountDTO, TCreateAccountDTO, TListAccountsDTO, TUpdateAccountDTO } from "./pam-account-types";
|
||||
|
||||
type TPamAccountServiceFactoryDep = {
|
||||
pamResourceDAL: TPamResourceDALFactory;
|
||||
@@ -251,17 +253,17 @@ export const pamAccountServiceFactory = ({
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...credentials };
|
||||
if (credentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
}
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||
credentials,
|
||||
decryptedCredentials
|
||||
);
|
||||
|
||||
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
@@ -334,21 +336,96 @@ export const pamAccountServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const list = async ({
|
||||
projectId,
|
||||
accountPath,
|
||||
accountView,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
...params
|
||||
}: TListAccountsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
|
||||
const limit = params.limit || 20;
|
||||
const offset = params.offset || 0;
|
||||
|
||||
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
|
||||
const folder = accountPath === "/" ? null : await pamFolderDAL.findByPath(projectId, accountPath);
|
||||
if (accountPath !== "/" && !folder) {
|
||||
return { accounts: [], folders: [], totalCount: 0, folderPaths: {} };
|
||||
}
|
||||
const folderId = folder?.id;
|
||||
|
||||
let totalFolderCount = 0;
|
||||
if (canReadFolders && accountView === PamAccountView.Nested) {
|
||||
const { totalCount } = await pamFolderDAL.findByProjectId({
|
||||
projectId,
|
||||
parentId: folderId,
|
||||
search: params.search
|
||||
});
|
||||
totalFolderCount = totalCount;
|
||||
}
|
||||
|
||||
let folders: TPamFolders[] = [];
|
||||
if (canReadFolders && accountView === PamAccountView.Nested && offset < totalFolderCount) {
|
||||
const folderLimit = Math.min(limit, totalFolderCount - offset);
|
||||
const { folders: foldersResp } = await pamFolderDAL.findByProjectId({
|
||||
projectId,
|
||||
parentId: folderId,
|
||||
limit: folderLimit,
|
||||
offset,
|
||||
search: params.search,
|
||||
orderBy: params.orderBy,
|
||||
orderDirection: params.orderDirection
|
||||
});
|
||||
|
||||
folders = foldersResp;
|
||||
}
|
||||
|
||||
let accountsWithResourceDetails: Awaited<
|
||||
ReturnType<typeof pamAccountDAL.findByProjectIdWithResourceDetails>
|
||||
>["accounts"] = [];
|
||||
let totalAccountCount = 0;
|
||||
|
||||
const accountsToFetch = limit - folders.length;
|
||||
if (accountsToFetch > 0) {
|
||||
const accountOffset = Math.max(0, offset - totalFolderCount);
|
||||
const { accounts, totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
|
||||
projectId,
|
||||
folderId,
|
||||
accountView,
|
||||
offset: accountOffset,
|
||||
limit: accountsToFetch,
|
||||
search: params.search,
|
||||
orderBy: params.orderBy,
|
||||
orderDirection: params.orderDirection,
|
||||
filterResourceIds: params.filterResourceIds
|
||||
});
|
||||
accountsWithResourceDetails = accounts;
|
||||
totalAccountCount = totalCount;
|
||||
} else {
|
||||
// if no accounts are to be fetched for the current page, we still need the total count for pagination
|
||||
const { totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
|
||||
projectId,
|
||||
folderId,
|
||||
accountView,
|
||||
search: params.search,
|
||||
filterResourceIds: params.filterResourceIds
|
||||
});
|
||||
totalAccountCount = totalCount;
|
||||
}
|
||||
|
||||
const totalCount = totalFolderCount + totalAccountCount;
|
||||
|
||||
const decryptedAndPermittedAccounts: Array<
|
||||
TPamAccounts & {
|
||||
@@ -359,12 +436,6 @@ export const pamAccountServiceFactory = ({
|
||||
> = [];
|
||||
|
||||
for await (const account of accountsWithResourceDetails) {
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
// Check permission for each individual account
|
||||
if (
|
||||
permission.can(
|
||||
@@ -391,14 +462,32 @@ export const pamAccountServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
const folderPaths: Record<string, string> = {};
|
||||
const accountFolderIds = [
|
||||
...new Set(decryptedAndPermittedAccounts.flatMap((a) => (a.folderId ? [a.folderId] : [])))
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
accountFolderIds.map(async (fId) => {
|
||||
folderPaths[fId] = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: fId,
|
||||
projectId
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
accounts: decryptedAndPermittedAccounts,
|
||||
folders
|
||||
folders,
|
||||
totalCount,
|
||||
folderId,
|
||||
folderPaths
|
||||
};
|
||||
};
|
||||
|
||||
const access = async (
|
||||
{ accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
|
||||
{ accountPath, projectId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
@@ -408,8 +497,36 @@ export const pamAccountServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
|
||||
const pathSegments: string[] = accountPath.split("/").filter(Boolean);
|
||||
if (pathSegments.length === 0) {
|
||||
throw new BadRequestError({ message: "Invalid accountPath. Path must contain at least the account name." });
|
||||
}
|
||||
|
||||
const accountName: string = pathSegments[pathSegments.length - 1] ?? "";
|
||||
const folderPathSegments: string[] = pathSegments.slice(0, -1);
|
||||
|
||||
const folderPath: string = folderPathSegments.length > 0 ? `/${folderPathSegments.join("/")}` : "/";
|
||||
|
||||
let folderId: string | null = null;
|
||||
if (folderPath !== "/") {
|
||||
const folder = await pamFolderDAL.findByPath(projectId, folderPath);
|
||||
if (!folder) {
|
||||
throw new NotFoundError({ message: `Folder at path '${folderPath}' not found` });
|
||||
}
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findOne({
|
||||
projectId,
|
||||
folderId,
|
||||
name: accountName
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new NotFoundError({
|
||||
message: `Account with name '${accountName}' not found at path '${accountPath}'`
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
@@ -419,22 +536,16 @@ export const pamAccountServiceFactory = ({
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Access,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
accountPath: folderPath
|
||||
})
|
||||
);
|
||||
|
||||
@@ -444,7 +555,7 @@ export const pamAccountServiceFactory = ({
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
resourceName: resource.name,
|
||||
resourceType: resource.resourceType,
|
||||
status: PamSessionStatus.Starting,
|
||||
@@ -453,11 +564,7 @@ export const pamAccountServiceFactory = ({
|
||||
expiresAt: new Date(Date.now() + duration)
|
||||
});
|
||||
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
|
||||
resource,
|
||||
account.projectId,
|
||||
kmsService
|
||||
);
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, projectId, kmsService);
|
||||
|
||||
const user = await userDAL.findById(actor.id);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
|
||||
@@ -486,23 +593,36 @@ export const pamAccountServiceFactory = ({
|
||||
case PamResource.Postgres:
|
||||
case PamResource.MySQL:
|
||||
{
|
||||
const connectionCredentials = await decryptResourceConnectionDetails({
|
||||
const connectionCredentials = (await decryptResourceConnectionDetails({
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
projectId
|
||||
})) as TSqlResourceConnectionDetails;
|
||||
|
||||
const credentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
projectId
|
||||
});
|
||||
|
||||
metadata = {
|
||||
username: credentials.username,
|
||||
database: connectionCredentials.database,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
accountPath: folderPath
|
||||
};
|
||||
}
|
||||
break;
|
||||
case PamResource.SSH:
|
||||
{
|
||||
const credentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId
|
||||
});
|
||||
|
||||
metadata = {
|
||||
username: credentials.username
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -520,7 +640,7 @@ export const pamAccountServiceFactory = ({
|
||||
gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey,
|
||||
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
|
||||
relayHost: gatewayConnectionDetails.relayHost,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
account,
|
||||
metadata
|
||||
};
|
||||
@@ -566,11 +686,6 @@ export const pamAccountServiceFactory = ({
|
||||
throw new BadRequestError({ message: "Session has ended or expired" });
|
||||
}
|
||||
|
||||
// Verify that the session has not already had credentials fetched
|
||||
if (session.status !== PamSessionStatus.Starting) {
|
||||
throw new BadRequestError({ message: "Session has already been started" });
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(session.accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
|
||||
|
||||
@@ -587,11 +702,16 @@ export const pamAccountServiceFactory = ({
|
||||
|
||||
const decryptedResource = await decryptResource(resource, session.projectId, kmsService);
|
||||
|
||||
let sessionStarted = false;
|
||||
|
||||
// Mark session as started
|
||||
await pamSessionDAL.updateById(sessionId, {
|
||||
status: PamSessionStatus.Active,
|
||||
startedAt: new Date()
|
||||
});
|
||||
if (session.status === PamSessionStatus.Starting) {
|
||||
await pamSessionDAL.updateById(sessionId, {
|
||||
status: PamSessionStatus.Active,
|
||||
startedAt: new Date()
|
||||
});
|
||||
sessionStarted = true;
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
@@ -599,7 +719,8 @@ export const pamAccountServiceFactory = ({
|
||||
...decryptedAccount.credentials
|
||||
},
|
||||
projectId: project.id,
|
||||
account
|
||||
account,
|
||||
sessionStarted
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { TPamAccount } from "../pam-resource/pam-resource-types";
|
||||
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||
|
||||
// DTOs
|
||||
export type TCreateAccountDTO = Pick<
|
||||
@@ -11,10 +14,22 @@ export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "re
|
||||
};
|
||||
|
||||
export type TAccessAccountDTO = {
|
||||
accountId: string;
|
||||
accountPath: string;
|
||||
projectId: string;
|
||||
actorEmail: string;
|
||||
actorIp: string;
|
||||
actorName: string;
|
||||
actorUserAgent: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type TListAccountsDTO = {
|
||||
accountPath: string;
|
||||
accountView: PamAccountView;
|
||||
search?: string;
|
||||
orderBy?: PamAccountOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filterResourceIds?: string[];
|
||||
} & TProjectPermission;
|
||||
|
||||
@@ -1,9 +1,106 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { PamAccountOrderBy } from "../pam-account/pam-account-enums";
|
||||
|
||||
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
|
||||
export const pamFolderDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamFolder);
|
||||
return { ...orm };
|
||||
|
||||
const findByProjectId = async (
|
||||
{
|
||||
projectId,
|
||||
parentId,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = PamAccountOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC
|
||||
}: {
|
||||
projectId: string;
|
||||
parentId?: string | null;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: PamAccountOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const dbInstance = tx || db.replicaNode();
|
||||
const query = dbInstance(TableName.PamFolder).where(`${TableName.PamFolder}.projectId`, projectId);
|
||||
|
||||
if (parentId) {
|
||||
void query.where(`${TableName.PamFolder}.parentId`, parentId);
|
||||
} else {
|
||||
void query.whereNull(`${TableName.PamFolder}.parentId`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
void query.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamFolder, "name", `%${escapedSearch}%`]);
|
||||
}
|
||||
|
||||
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||
|
||||
void query.select(selectAllTableCols(TableName.PamFolder));
|
||||
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||
|
||||
void query.orderByRaw(`${TableName.PamFolder}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||
|
||||
if (typeof limit === "number") {
|
||||
void query.limit(limit).offset(offset);
|
||||
}
|
||||
|
||||
const [folders, countResult] = await Promise.all([query, countQuery]);
|
||||
const totalCount = Number(countResult?.count || 0);
|
||||
|
||||
return { folders, totalCount };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find PAM folders" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByPath = async (projectId: string, path: string, tx?: Knex) => {
|
||||
try {
|
||||
const dbInstance = tx || db.replicaNode();
|
||||
const pathSegments = path.split("/").filter(Boolean);
|
||||
|
||||
let parentId: string | null = null;
|
||||
let currentFolder: Awaited<ReturnType<typeof orm.findOne>> | undefined;
|
||||
|
||||
for await (const segment of pathSegments) {
|
||||
const query = dbInstance(TableName.PamFolder)
|
||||
.where(`${TableName.PamFolder}.projectId`, projectId)
|
||||
.where(`${TableName.PamFolder}.name`, segment);
|
||||
|
||||
if (parentId) {
|
||||
void query.where(`${TableName.PamFolder}.parentId`, parentId);
|
||||
} else {
|
||||
void query.whereNull(`${TableName.PamFolder}.parentId`);
|
||||
}
|
||||
|
||||
currentFolder = await query.first();
|
||||
|
||||
if (!currentFolder) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
parentId = currentFolder.id;
|
||||
}
|
||||
|
||||
return currentFolder;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find PAM folder by path" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...orm, findByProjectId, findByPath };
|
||||
};
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { PamResourceOrderBy } from "./pam-resource-enums";
|
||||
|
||||
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
|
||||
export const pamResourceDALFactory = (db: TDbClient) => {
|
||||
@@ -20,5 +24,65 @@ export const pamResourceDALFactory = (db: TDbClient) => {
|
||||
return doc;
|
||||
};
|
||||
|
||||
return { ...orm, findById };
|
||||
const findByProjectId = async (
|
||||
{
|
||||
projectId,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = PamResourceOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
filterResourceTypes
|
||||
}: {
|
||||
projectId: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: PamResourceOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
filterResourceTypes?: string[];
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const dbInstance = tx || db.replicaNode();
|
||||
const query = dbInstance(TableName.PamResource).where(`${TableName.PamResource}.projectId`, projectId);
|
||||
|
||||
if (search) {
|
||||
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
const pattern = `%${escapedSearch}%`;
|
||||
void query.where((q) => {
|
||||
void q
|
||||
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
|
||||
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "resourceType", pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
if (filterResourceTypes && filterResourceTypes.length) {
|
||||
void query.whereIn(`${TableName.PamResource}.resourceType`, filterResourceTypes);
|
||||
}
|
||||
|
||||
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||
|
||||
void query.select(selectAllTableCols(TableName.PamResource));
|
||||
|
||||
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||
|
||||
void query.orderByRaw(`${TableName.PamResource}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||
|
||||
if (typeof limit === "number") {
|
||||
void query.limit(limit).offset(offset);
|
||||
}
|
||||
|
||||
const [resources, countResult] = await Promise.all([query, countQuery]);
|
||||
const totalCount = Number(countResult?.count || 0);
|
||||
|
||||
return { resources, totalCount };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find PAM resources" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...orm, findById, findByProjectId };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql"
|
||||
MySQL = "mysql",
|
||||
SSH = "ssh"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
||||
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
||||
import { sshResourceFactory } from "./ssh/ssh-resource-factory";
|
||||
|
||||
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
||||
|
||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation
|
||||
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
encryptResourceConnectionDetails,
|
||||
listResourceOptions
|
||||
} from "./pam-resource-fns";
|
||||
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||
import { TCreateResourceDTO, TListResourcesDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||
|
||||
type TPamResourceServiceFactoryDep = {
|
||||
pamResourceDAL: TPamResourceDALFactory;
|
||||
@@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...rotationAccountCredentials };
|
||||
if (
|
||||
resource.encryptedRotationAccountCredentials &&
|
||||
rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__"
|
||||
) {
|
||||
let finalCredentials = { ...rotationAccountCredentials };
|
||||
if (resource.encryptedRotationAccountCredentials) {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: resource.encryptedRotationAccountCredentials,
|
||||
projectId: resource.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||
rotationAccountCredentials,
|
||||
decryptedCredentials
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -268,22 +267,23 @@ export const pamResourceServiceFactory = ({
|
||||
}
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const list = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId, ...params }: TListResourcesDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
const resources = await pamResourceDAL.find({ projectId });
|
||||
const { resources, totalCount } = await pamResourceDAL.findByProjectId({ projectId, ...params });
|
||||
|
||||
return {
|
||||
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService)))
|
||||
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))),
|
||||
totalCount
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import {
|
||||
TMySQLAccount,
|
||||
@@ -5,22 +7,31 @@ import {
|
||||
TMySQLResource,
|
||||
TMySQLResourceConnectionDetails
|
||||
} from "./mysql/mysql-resource-types";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { PamResource, PamResourceOrderBy } from "./pam-resource-enums";
|
||||
import {
|
||||
TPostgresAccount,
|
||||
TPostgresAccountCredentials,
|
||||
TPostgresResource,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "./postgres/postgres-resource-types";
|
||||
import {
|
||||
TSSHAccount,
|
||||
TSSHAccountCredentials,
|
||||
TSSHResource,
|
||||
TSSHResourceConnectionDetails
|
||||
} from "./ssh/ssh-resource-types";
|
||||
|
||||
// Resource types
|
||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
||||
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails;
|
||||
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||
export type TPamResourceConnectionDetails =
|
||||
| TPostgresResourceConnectionDetails
|
||||
| TMySQLResourceConnectionDetails
|
||||
| TSSHResourceConnectionDetails;
|
||||
|
||||
// Account types
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials;
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials;
|
||||
|
||||
// Resource DTOs
|
||||
export type TCreateResourceDTO = Pick<
|
||||
@@ -32,6 +43,15 @@ export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType"
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export type TListResourcesDTO = {
|
||||
search?: string;
|
||||
orderBy?: PamResourceOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filterResourceTypes?: string[];
|
||||
} & TProjectPermission;
|
||||
|
||||
// Resource factory
|
||||
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
@@ -51,4 +71,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
|
||||
handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise<C>;
|
||||
};
|
||||
|
||||
@@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverwritePreventionForCensoredValues = async (
|
||||
updatedAccountCredentials: TSqlAccountCredentials,
|
||||
currentCredentials: TSqlAccountCredentials
|
||||
) => {
|
||||
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
password: currentCredentials.password
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAccountCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials,
|
||||
rotateAccountCredentials
|
||||
rotateAccountCredentials,
|
||||
handleOverwritePreventionForCensoredValues
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum SSHAuthMethod {
|
||||
Password = "password",
|
||||
PublicKey = "public-key",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Client } from "ssh2";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
TPamResourceFactory,
|
||||
TPamResourceFactoryRotateAccountCredentials,
|
||||
TPamResourceFactoryValidateAccountCredentials
|
||||
} from "../pam-resource-types";
|
||||
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||
import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
export const executeWithGateway = async <T>(
|
||||
config: {
|
||||
connectionDetails: TSSHResourceConnectionDetails;
|
||||
resourceType: PamResource;
|
||||
gatewayId: string;
|
||||
},
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||
operation: (proxyPort: number) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { connectionDetails, gatewayId } = config;
|
||||
const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true);
|
||||
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
|
||||
gatewayId,
|
||||
targetHost,
|
||||
targetPort: connectionDetails.port
|
||||
});
|
||||
|
||||
if (!platformConnectionDetails) {
|
||||
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
|
||||
}
|
||||
|
||||
return withGatewayV2Proxy(
|
||||
async (proxyPort) => {
|
||||
return operation(proxyPort);
|
||||
},
|
||||
{
|
||||
protocol: GatewayProxyProtocol.Tcp,
|
||||
relayHost: platformConnectionDetails.relayHost,
|
||||
gateway: platformConnectionDetails.gateway,
|
||||
relay: platformConnectionDetails.relay
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetails, TSSHAccountCredentials> = (
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
) => {
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let handshakeComplete = false;
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.info(
|
||||
{ error: err.message, handshakeComplete },
|
||||
"[SSH Resource Factory] SSH client error event received"
|
||||
);
|
||||
// If we got an authentication error, it means we successfully reached the SSH server
|
||||
// and completed the SSH handshake - that's good enough for connection validation
|
||||
if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) {
|
||||
logger.info(
|
||||
{ handshakeComplete, errorMessage: err.message },
|
||||
"[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)"
|
||||
);
|
||||
client.end();
|
||||
resolve();
|
||||
} else {
|
||||
logger.error(
|
||||
{ error: err.message, handshakeComplete },
|
||||
"[SSH Resource Factory] SSH connection validation failed"
|
||||
);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("handshake", () => {
|
||||
// SSH handshake completed - the server is reachable and responding
|
||||
logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true");
|
||||
handshakeComplete = true;
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("timeout", () => {
|
||||
logger.error("[SSH Resource Factory] SSH connection timeout");
|
||||
reject(new Error("Connection timeout"));
|
||||
});
|
||||
|
||||
// Attempt connection with a dummy username (we don't care about auth success)
|
||||
// The goal is just to verify SSH server is reachable and responding
|
||||
client.connect({
|
||||
host: "localhost",
|
||||
port: proxyPort,
|
||||
username: "infisical-connection-test",
|
||||
password: "infisical-connection-test-password",
|
||||
readyTimeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
tryKeyboard: false,
|
||||
// We want to fail fast on auth, we're just testing reachability
|
||||
authHandler: () => {
|
||||
// If authHandler is called, SSH handshake succeeded
|
||||
handshakeComplete = true;
|
||||
return false; // Don't continue with auth
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return connectionDetails;
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSSHAccountCredentials> = async (
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
|
||||
client.on("ready", () => {
|
||||
logger.info(
|
||||
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication successful"
|
||||
);
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error(
|
||||
{ error: err.message, username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication failed"
|
||||
);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on("timeout", () => {
|
||||
logger.error(
|
||||
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication timeout"
|
||||
);
|
||||
reject(new Error("Connection timeout"));
|
||||
});
|
||||
|
||||
// Build connection config based on auth method
|
||||
const baseConfig = {
|
||||
host: "localhost",
|
||||
port: proxyPort,
|
||||
username: credentials.username,
|
||||
readyTimeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
};
|
||||
|
||||
switch (credentials.authMethod) {
|
||||
case SSHAuthMethod.Password:
|
||||
client.connect({
|
||||
...baseConfig,
|
||||
password: credentials.password,
|
||||
tryKeyboard: false
|
||||
});
|
||||
break;
|
||||
case SSHAuthMethod.PublicKey:
|
||||
client.connect({
|
||||
...baseConfig,
|
||||
privateKey: credentials.privateKey,
|
||||
tryKeyboard: false
|
||||
});
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Check for common authentication failure messages
|
||||
if (
|
||||
error.message.includes("authentication") ||
|
||||
error.message.includes("All configured authentication methods failed") ||
|
||||
error.message.includes("publickey")
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Account credentials invalid."
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Connection timeout") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection timeout. Verify that the SSH server is reachable"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSSHAccountCredentials> = async (
|
||||
rotationAccountCredentials
|
||||
) => {
|
||||
return rotationAccountCredentials;
|
||||
};
|
||||
|
||||
const handleOverwritePreventionForCensoredValues = async (
|
||||
updatedAccountCredentials: TSSHAccountCredentials,
|
||||
currentCredentials: TSSHAccountCredentials
|
||||
) => {
|
||||
if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) {
|
||||
return updatedAccountCredentials;
|
||||
}
|
||||
|
||||
if (
|
||||
updatedAccountCredentials.authMethod === SSHAuthMethod.Password &&
|
||||
currentCredentials.authMethod === SSHAuthMethod.Password
|
||||
) {
|
||||
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
password: currentCredentials.password
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey &&
|
||||
currentCredentials.authMethod === SSHAuthMethod.PublicKey
|
||||
) {
|
||||
if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
privateKey: currentCredentials.privateKey
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updatedAccountCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials,
|
||||
rotateAccountCredentials,
|
||||
handleOverwritePreventionForCensoredValues
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
BaseCreatePamAccountSchema,
|
||||
BaseCreatePamResourceSchema,
|
||||
BasePamAccountSchema,
|
||||
BasePamAccountSchemaWithResource,
|
||||
BasePamResourceSchema,
|
||||
BaseUpdatePamAccountSchema,
|
||||
BaseUpdatePamResourceSchema
|
||||
} from "../pam-resource-schemas";
|
||||
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||
|
||||
export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) });
|
||||
|
||||
export const SSHResourceListItemSchema = z.object({
|
||||
name: z.literal("SSH"),
|
||||
resource: z.literal(PamResource.SSH)
|
||||
});
|
||||
|
||||
export const SSHResourceConnectionDetailsSchema = z.object({
|
||||
host: z.string().trim().max(255),
|
||||
port: z.number()
|
||||
});
|
||||
|
||||
export const SSHPasswordCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string().trim().max(255),
|
||||
password: z.string().trim().max(255)
|
||||
});
|
||||
|
||||
export const SSHPublicKeyCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string().trim().max(255),
|
||||
privateKey: z.string().trim().max(5000)
|
||||
});
|
||||
|
||||
export const SSHCertificateCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string().trim().max(255)
|
||||
});
|
||||
|
||||
export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [
|
||||
SSHPasswordCredentialsSchema,
|
||||
SSHPublicKeyCredentialsSchema,
|
||||
SSHCertificateCredentialsSchema
|
||||
]);
|
||||
|
||||
export const SSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: z
|
||||
.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string()
|
||||
})
|
||||
])
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const SSHAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema.optional()
|
||||
});
|
||||
|
||||
export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||
credentials: z.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string()
|
||||
})
|
||||
])
|
||||
});
|
||||
|
||||
// Sessions
|
||||
export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
SSHAccountCredentialsSchema,
|
||||
SSHAccountSchema,
|
||||
SSHResourceConnectionDetailsSchema,
|
||||
SSHResourceSchema
|
||||
} from "./ssh-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export type TSSHResource = z.infer<typeof SSHResourceSchema>;
|
||||
export type TSSHResourceConnectionDetails = z.infer<typeof SSHResourceConnectionDetailsSchema>;
|
||||
|
||||
// Accounts
|
||||
export type TSSHAccount = z.infer<typeof SSHAccountSchema>;
|
||||
export type TSSHAccountCredentials = z.infer<typeof SSHAccountCredentialsSchema>;
|
||||
@@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session-types";
|
||||
|
||||
export const decryptSessionCommandLogs = async ({
|
||||
projectId,
|
||||
@@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({
|
||||
cipherTextBlob: encryptedLogs
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[];
|
||||
};
|
||||
|
||||
export const decryptSession = async (
|
||||
@@ -32,7 +32,7 @@ export const decryptSession = async (
|
||||
) => {
|
||||
return {
|
||||
...session,
|
||||
commandLogs: session.encryptedLogsBlob
|
||||
logs: session.encryptedLogsBlob
|
||||
? await decryptSessionCommandLogs({
|
||||
projectId,
|
||||
encryptedLogs: session.encryptedLogsBlob,
|
||||
|
||||
@@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({
|
||||
timestamp: z.coerce.date()
|
||||
});
|
||||
|
||||
// SSH Terminal Event schemas
|
||||
export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]);
|
||||
|
||||
export const TerminalEventSchema = z.object({
|
||||
timestamp: z.coerce.date(),
|
||||
eventType: TerminalEventTypeSchema,
|
||||
data: z.string(), // Base64 encoded binary data
|
||||
elapsedTime: z.number() // Seconds since session start (for replay)
|
||||
});
|
||||
|
||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
}).extend({
|
||||
commandLogs: PamSessionCommandLogSchema.array()
|
||||
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||
});
|
||||
|
||||
@@ -12,10 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session.types";
|
||||
import { TPamSessionDALFactory } from "./pam-session-dal";
|
||||
import { PamSessionStatus } from "./pam-session-enums";
|
||||
import { decryptSession } from "./pam-session-fns";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session-types";
|
||||
|
||||
type TPamSessionServiceFactoryDep = {
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas";
|
||||
|
||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||
export type TTerminalEvent = z.infer<typeof TerminalEventSchema>;
|
||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||
|
||||
// DTOs
|
||||
export type TUpdateSessionLogsDTO = {
|
||||
sessionId: string;
|
||||
logs: TPamSessionCommandLog[];
|
||||
logs: (TPamSessionCommandLog | TTerminalEvent)[];
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ProjectPermissionAppConnectionActions,
|
||||
ProjectPermissionAuditLogsActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCertificateAuthorityActions,
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionCommitsActions,
|
||||
@@ -44,9 +45,7 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.Settings,
|
||||
ProjectPermissionSub.Environments,
|
||||
ProjectPermissionSub.Tags,
|
||||
ProjectPermissionSub.AuditLogs,
|
||||
ProjectPermissionSub.IpAllowList,
|
||||
ProjectPermissionSub.CertificateAuthorities,
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
@@ -67,6 +66,20 @@ const buildAdminPermissionRules = () => {
|
||||
);
|
||||
});
|
||||
|
||||
can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionCertificateAuthorityActions.Read,
|
||||
ProjectPermissionCertificateAuthorityActions.Create,
|
||||
ProjectPermissionCertificateAuthorityActions.Edit,
|
||||
ProjectPermissionCertificateAuthorityActions.Delete,
|
||||
ProjectPermissionCertificateAuthorityActions.Renew,
|
||||
ProjectPermissionCertificateAuthorityActions.SignIntermediate
|
||||
],
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionPkiTemplateActions.Read,
|
||||
@@ -95,7 +108,8 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionCertificateActions.Edit,
|
||||
ProjectPermissionCertificateActions.Create,
|
||||
ProjectPermissionCertificateActions.Delete,
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey,
|
||||
ProjectPermissionCertificateActions.Import
|
||||
],
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
@@ -460,7 +474,7 @@ const buildMemberPermissionRules = () => {
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList);
|
||||
|
||||
// double check if all CRUD are needed for CA and Certificates
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities);
|
||||
can([ProjectPermissionCertificateAuthorityActions.Read], ProjectPermissionSub.CertificateAuthorities);
|
||||
can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
|
||||
|
||||
can(
|
||||
@@ -468,7 +482,8 @@ const buildMemberPermissionRules = () => {
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionCertificateActions.Edit,
|
||||
ProjectPermissionCertificateActions.Create,
|
||||
ProjectPermissionCertificateActions.Delete
|
||||
ProjectPermissionCertificateActions.Delete,
|
||||
ProjectPermissionCertificateActions.Import
|
||||
],
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
@@ -599,7 +614,7 @@ const buildViewerPermissionRules = () => {
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||
can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
|
||||
can(ProjectPermissionCertificateAuthorityActions.Read, ProjectPermissionSub.CertificateAuthorities);
|
||||
can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates);
|
||||
can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates);
|
||||
can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek);
|
||||
|
||||
@@ -23,12 +23,22 @@ export enum ProjectPermissionCommitsActions {
|
||||
PerformRollback = "perform-rollback"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCertificateAuthorityActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
Renew = "renew",
|
||||
SignIntermediate = "sign-intermediate"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCertificateActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
ReadPrivateKey = "read-private-key"
|
||||
ReadPrivateKey = "read-private-key",
|
||||
Import = "import"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretActions {
|
||||
@@ -292,7 +302,8 @@ export type SecretSyncSubjectFields = {
|
||||
};
|
||||
|
||||
export type PkiSyncSubjectFields = {
|
||||
subscriberName: string;
|
||||
subscriberName?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DynamicSecretSubjectFields = {
|
||||
@@ -332,6 +343,26 @@ export type PkiSubscriberSubjectFields = {
|
||||
// (dangtony98): consider adding [commonName] as a subject field in the future
|
||||
};
|
||||
|
||||
export type CertificateAuthoritySubjectFields = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CertificateSubjectFields = {
|
||||
commonName?: string;
|
||||
altNames?: string;
|
||||
serialNumber?: string;
|
||||
friendlyName?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type CertificateProfileSubjectFields = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type CertificateTemplateV2SubjectFields = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type AppConnectionSubjectFields = {
|
||||
connectionId: string;
|
||||
};
|
||||
@@ -399,8 +430,17 @@ export type ProjectPermissionSet =
|
||||
ProjectPermissionIdentityActions,
|
||||
ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates]
|
||||
| [
|
||||
ProjectPermissionCertificateAuthorityActions,
|
||||
(
|
||||
| ProjectPermissionSub.CertificateAuthorities
|
||||
| (ForcedSubject<ProjectPermissionSub.CertificateAuthorities> & CertificateAuthoritySubjectFields)
|
||||
)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub.Certificates | (ForcedSubject<ProjectPermissionSub.Certificates> & CertificateSubjectFields)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
(
|
||||
@@ -454,7 +494,13 @@ export type ProjectPermissionSet =
|
||||
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
|
||||
]
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
|
||||
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles];
|
||||
| [
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
(
|
||||
| ProjectPermissionSub.CertificateProfiles
|
||||
| (ForcedSubject<ProjectPermissionSub.CertificateProfiles> & CertificateProfileSubjectFields)
|
||||
)
|
||||
];
|
||||
|
||||
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
@@ -572,6 +618,17 @@ const SecretSyncConditionV2Schema = z
|
||||
|
||||
const PkiSyncConditionSchema = z
|
||||
.object({
|
||||
name: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
subscriberName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
@@ -698,6 +755,7 @@ const PkiTemplateConditionSchema = z
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
|
||||
})
|
||||
@@ -749,6 +807,98 @@ const PamAccountConditionSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
const CertificateAuthorityConditionSchema = z
|
||||
.object({
|
||||
name: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
])
|
||||
})
|
||||
.partial();
|
||||
|
||||
const CertificateConditionSchema = z
|
||||
.object({
|
||||
commonName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
altNames: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
serialNumber: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
friendlyName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
status: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
])
|
||||
})
|
||||
.partial();
|
||||
|
||||
const CertificateProfileConditionSchema = z
|
||||
.object({
|
||||
slug: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
])
|
||||
})
|
||||
.partial();
|
||||
|
||||
const GeneralPermissionSchema = [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
|
||||
@@ -828,18 +978,6 @@ const GeneralPermissionSchema = [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.CertificateAuthorities).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z
|
||||
.literal(ProjectPermissionSub.SshCertificateAuthorities)
|
||||
@@ -1130,7 +1268,30 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
),
|
||||
conditions: CertificateProfileConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.CertificateAuthorities).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateAuthorityActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: CertificateAuthorityConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: CertificateConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
...GeneralPermissionSchema
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import { TPkiAcmeChallenges } from "@app/db/schemas/pki-acme-challenges";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { isPrivateIp } from "@app/lib/ip/ipRange";
|
||||
@@ -13,14 +16,14 @@ import {
|
||||
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
|
||||
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||
|
||||
type FetchError = Error & {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
type TPkiAcmeChallengeServiceFactoryDep = {
|
||||
acmeChallengeDAL: Pick<
|
||||
TPkiAcmeChallengeDALFactory,
|
||||
"transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById"
|
||||
| "transaction"
|
||||
| "findByIdForChallengeValidation"
|
||||
| "markAsValidCascadeById"
|
||||
| "markAsInvalidCascadeById"
|
||||
| "updateById"
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -28,9 +31,8 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
acmeChallengeDAL
|
||||
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const validateChallengeResponse = async (challengeId: string): Promise<void> => {
|
||||
const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => {
|
||||
const markChallengeAsReady = async (challengeId: string): Promise<TPkiAcmeChallenges> => {
|
||||
return acmeChallengeDAL.transaction(async (tx) => {
|
||||
logger.info({ challengeId }, "Validating ACME challenge response");
|
||||
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx);
|
||||
if (!challenge) {
|
||||
@@ -54,84 +56,102 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
if (challenge.type !== AcmeChallengeType.HTTP_01) {
|
||||
throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" });
|
||||
}
|
||||
let host = challenge.auth.identifierValue;
|
||||
const host = challenge.auth.identifierValue;
|
||||
// check if host is a private ip address
|
||||
if (isPrivateIp(host)) {
|
||||
throw new BadRequestError({ message: "Private IP addresses are not allowed" });
|
||||
}
|
||||
if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) {
|
||||
host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host];
|
||||
logger.warn(
|
||||
{ srcHost: challenge.auth.identifierValue, dstHost: host },
|
||||
"Using ACME development HTTP-01 challenge host override"
|
||||
);
|
||||
}
|
||||
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`);
|
||||
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
|
||||
try {
|
||||
// TODO: read config from the profile to get the timeout instead
|
||||
const timeoutMs = 10 * 1000; // 10 seconds
|
||||
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
||||
// a long running operation for long time. But assuming we are not performing a tons of
|
||||
// challenge validation at the same time, it should be fine.
|
||||
const challengeResponse = await fetch(challengeUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (challengeResponse.status !== 200) {
|
||||
throw new AcmeIncorrectResponseError({
|
||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||
});
|
||||
}
|
||||
const challengeResponseBody = await challengeResponse.text();
|
||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
||||
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
||||
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
|
||||
}
|
||||
await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx);
|
||||
} catch (exp) {
|
||||
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now
|
||||
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
|
||||
// Properly type and inspect the error
|
||||
if (exp instanceof TypeError && exp.message.includes("fetch failed")) {
|
||||
const { cause } = exp;
|
||||
let errors: Error[] = [];
|
||||
if (cause instanceof AggregateError) {
|
||||
errors = cause.errors as Error[];
|
||||
} else if (cause instanceof Error) {
|
||||
errors = [cause];
|
||||
}
|
||||
// eslint-disable-next-line no-unreachable-loop
|
||||
for (const err of errors) {
|
||||
// TODO: handle multiple errors, return a compound error instead of just the first error
|
||||
const fetchError = err as FetchError;
|
||||
if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) {
|
||||
return new AcmeConnectionError({ message: "Connection refused" });
|
||||
}
|
||||
if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) {
|
||||
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
} else if (exp instanceof DOMException) {
|
||||
if (exp.name === "TimeoutError") {
|
||||
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
||||
return new AcmeConnectionError({ message: "Connection timed out" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
} else if (exp instanceof Error) {
|
||||
logger.error(exp, "Error validating ACME challenge response");
|
||||
} else {
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
return exp;
|
||||
}
|
||||
return acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Processing }, tx);
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const validateChallengeResponse = async (challengeId: string, retryCount: number): Promise<void> => {
|
||||
logger.info({ challengeId, retryCount }, "Validating ACME challenge response");
|
||||
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId);
|
||||
if (!challenge) {
|
||||
throw new NotFoundError({ message: "ACME challenge not found" });
|
||||
}
|
||||
if (challenge.status !== AcmeChallengeStatus.Processing) {
|
||||
throw new BadRequestError({
|
||||
message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}`
|
||||
});
|
||||
}
|
||||
let host = challenge.auth.identifierValue;
|
||||
if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) {
|
||||
host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host];
|
||||
logger.warn(
|
||||
{ srcHost: challenge.auth.identifierValue, dstHost: host },
|
||||
"Using ACME development HTTP-01 challenge host override"
|
||||
);
|
||||
}
|
||||
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`);
|
||||
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
|
||||
try {
|
||||
// TODO: read config from the profile to get the timeout instead
|
||||
const timeoutMs = 10 * 1000; // 10 seconds
|
||||
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
||||
// a long running operation for long time. But assuming we are not performing a tons of
|
||||
// challenge validation at the same time, it should be fine.
|
||||
const challengeResponse = await axios.get<string>(challengeUrl.toString(), {
|
||||
// In case if we override the host in the development mode, still provide the original host in the header
|
||||
// to help the upstream server to validate the request
|
||||
headers: { Host: challenge.auth.identifierValue },
|
||||
timeout: timeoutMs,
|
||||
responseType: "text",
|
||||
validateStatus: () => true
|
||||
});
|
||||
if (challengeResponse.status !== 200) {
|
||||
throw new AcmeIncorrectResponseError({
|
||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||
});
|
||||
}
|
||||
const challengeResponseBody: string = challengeResponse.data;
|
||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
||||
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
||||
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
|
||||
}
|
||||
logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid");
|
||||
await acmeChallengeDAL.markAsValidCascadeById(challengeId);
|
||||
} catch (exp) {
|
||||
if (retryCount >= 2) {
|
||||
logger.error(
|
||||
exp,
|
||||
`Last attempt to validate ACME challenge response failed, marking ${challengeId} challenge as invalid`
|
||||
);
|
||||
// This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid
|
||||
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId);
|
||||
}
|
||||
// Properly type and inspect the error
|
||||
if (axios.isAxiosError(exp)) {
|
||||
const axiosError = exp as AxiosError;
|
||||
const errorCode = axiosError.code;
|
||||
const errorMessage = axiosError.message;
|
||||
|
||||
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
|
||||
throw new AcmeConnectionError({ message: "Connection refused" });
|
||||
}
|
||||
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
|
||||
throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||
}
|
||||
if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) {
|
||||
throw new AcmeConnectionError({ message: "Connection reset by peer" });
|
||||
}
|
||||
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
|
||||
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
||||
throw new AcmeConnectionError({ message: "Connection timed out" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
if (exp instanceof Error) {
|
||||
logger.error(exp, "Error validating ACME challenge response");
|
||||
throw exp;
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
};
|
||||
|
||||
return { validateChallengeResponse };
|
||||
return { markChallengeAsReady, validateChallengeResponse };
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AcmeAccountDoesNotExistError } from "./pki-acme-errors";
|
||||
export const buildUrl = (profileId: string, path: string): string => {
|
||||
const appCfg = getConfig();
|
||||
const baseUrl = appCfg.SITE_URL ?? "";
|
||||
return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`;
|
||||
return `${baseUrl}/api/v1/cert-manager/acme/profiles/${profileId}${path}`;
|
||||
};
|
||||
|
||||
export const extractAccountIdFromKid = (kid: string, profileId: string): string => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||
|
||||
type TPkiAcmeQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
|
||||
};
|
||||
|
||||
export type TPkiAcmeQueueServiceFactory = Awaited<ReturnType<typeof pkiAcmeQueueServiceFactory>>;
|
||||
|
||||
export const pkiAcmeQueueServiceFactory = async ({
|
||||
queueService,
|
||||
acmeChallengeService
|
||||
}: TPkiAcmeQueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// Initialize the worker to process challenge validation jobs
|
||||
await queueService.startPg<QueueName.PkiAcmeChallengeValidation>(
|
||||
QueueJobs.PkiAcmeChallengeValidation,
|
||||
async ([job]) => {
|
||||
const { challengeId } = job.data;
|
||||
const retryCount = job.retryCount || 0;
|
||||
try {
|
||||
logger.info({ challengeId, retryCount }, "Processing ACME challenge validation job");
|
||||
await acmeChallengeService.validateChallengeResponse(challengeId, retryCount);
|
||||
logger.info({ challengeId, retryCount }, "ACME challenge validation completed successfully");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
error,
|
||||
`Failed to validate ACME challenge ${challengeId} (retryCount ${retryCount}): ${errorMessage}`
|
||||
);
|
||||
// Re-throw to let pg-boss handle retries with exponential backoff
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 2,
|
||||
pollingIntervalSeconds: 1
|
||||
}
|
||||
);
|
||||
|
||||
const queueChallengeValidation = async (challengeId: string): Promise<void> => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ challengeId }, "Queueing ACME challenge validation");
|
||||
await queueService.queuePg(
|
||||
QueueJobs.PkiAcmeChallengeValidation,
|
||||
{ challengeId },
|
||||
{
|
||||
retryLimit: 3,
|
||||
retryDelay: 30, // Base delay of 30 seconds
|
||||
retryBackoff: true // Exponential backoff: 30s, 60s, 120s
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
queueChallengeValidation
|
||||
};
|
||||
};
|
||||
@@ -58,7 +58,15 @@ export const GetAcmeDirectoryResponseSchema = z.object({
|
||||
newNonce: z.string(),
|
||||
newAccount: z.string(),
|
||||
newOrder: z.string(),
|
||||
revokeCert: z.string().optional()
|
||||
revokeCert: z.string().optional(),
|
||||
meta: z
|
||||
.object({
|
||||
termsOfService: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
caaIdentities: z.array(z.string()).optional(),
|
||||
externalAccountRequired: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
// New Account payload schema
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user