Merge remote-tracking branch 'origin/main' into feat/ENG-3443

This commit is contained in:
Carlos Monastyrski
2025-12-08 10:22:30 -03:00
1142 changed files with 33462 additions and 13730 deletions
+2 -13
View File
@@ -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=
+19 -17
View File
@@ -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)
+55
View File
@@ -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}"`);
}
+1
View File
@@ -74,3 +74,4 @@ cli/test/infisical-merge
backend/bdd/.bdd-infisical-bootstrap-result.json
/npm/bin
__pycache__
+4
View File
@@ -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
+3
View File
@@ -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
+4
View File
@@ -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
+14 -9
View File
@@ -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} |
+34 -15
View File
@@ -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 -2
View File
@@ -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
+38 -52
View File
@@ -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"
]
"""
+21 -21
View File
@@ -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} |
+14 -14
View File
@@ -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": {
+235 -35
View File
@@ -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}")
+4 -3
View File
@@ -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"
});
+4 -2
View File
@@ -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"
}
+72 -3
View File
@@ -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",
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -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 });
+75
View File
@@ -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
+2
View File
@@ -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;
+10
View File
@@ -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]
);
}
}
}
@@ -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>>;
+1
View File
@@ -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";
+1
View File
@@ -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>;
+2 -1
View File
@@ -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
};
+93 -3
View File
@@ -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,
+16 -4
View File
@@ -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 };
}
});
};
+31 -9
View File
@@ -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({
+12 -11
View File
@@ -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({
+1 -1
View File
@@ -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
@@ -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
});
+86 -1
View File
@@ -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
+59 -1
View File
@@ -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
+20 -1
View File
@@ -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
};
};
+45 -2
View File
@@ -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;
@@ -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