diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index befc96b17..7a9004ee8 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -414,28 +414,18 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Install cosign - # cosign is used to sign and verify container images (key and keyless) + # cosign is used to sign container images using keyless (OIDC) signing uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - - name: Dual-sign and verify (GHCR & Docker Hub) - # Sign each image by digest using keyless (OIDC) and key-based signing, - # then verify both the public key signature and the keyless OIDC signature. + - name: Sign (GHCR, keyless) + # Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor. + # Signatures are stored in the registry alongside the image. env: TAG: ${{ env.TAG }} - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} COSIGN_YES: "true" run: | set -euo pipefail - issuer="https://token.actions.githubusercontent.com" - id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) - - # Track failures - FAILED_TAGS=() - SUCCESSFUL_TAGS=() - # Determine if this is an RC release IS_RC="false" if [[ "$TAG" == *"-rc."* ]]; then @@ -463,95 +453,47 @@ jobs: ) fi - # Sign each image variant for both registries - for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do - for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do - echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" - TAG_FAILED=false + FAILED_TAGS=() + SUCCESSFUL_TAGS=() - # Wrap the entire tag processing in error handling - ( - set -e - DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" - REF="${BASE_IMAGE}@${DIGEST}" - echo "Resolved digest: ${REF}" + for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do + echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}" + TAG_FAILED=false - echo "==> cosign sign (keyless) --recursive ${REF}" - cosign sign --recursive "${REF}" + ( + set -e + DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" + REF="${GHCR_IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" + ) || TAG_FAILED=true - # Retry wrapper for verification to handle registry propagation delays - retry_verify() { - local cmd="$1" - local attempts=6 - local delay=5 - local i=1 - until eval "$cmd"; do - if [ $i -ge $attempts ]; then - echo "Verification failed after $attempts attempts" - return 1 - fi - echo "Verification not yet available. Retry $i/$attempts after ${delay}s..." - sleep $delay - i=$((i+1)) - delay=$((delay*2)) - # Cap the delay to avoid very long waits - if [ $delay -gt 60 ]; then delay=60; fi - done - return 0 - } - - echo "==> cosign verify (public key) ${REF}" - if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then - VERIFIED_INDEX=true - else - VERIFIED_INDEX=false - fi - - echo "==> cosign verify (keyless policy) ${REF}" - if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then - VERIFIED_INDEX_KEYLESS=true - else - VERIFIED_INDEX_KEYLESS=false - fi - - # Check if verification succeeded - if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then - echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}" - echo "This may be due to registry propagation delays. Continuing anyway." - fi - ) || TAG_FAILED=true - - if [ "$TAG_FAILED" = "true" ]; then - echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}" - FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") - else - echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" - SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") - fi - done + if [ "$TAG_FAILED" = "true" ]; then + echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}" + FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") + else + echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}" + SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") + fi done - # Report summary echo "" echo "==========================================" - echo "Sign and Verify Summary" + echo "Sign Summary" echo "==========================================" echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}" - echo "" if [ ${#FAILED_TAGS[@]} -gt 0 ]; then echo "Failed tags:" for tag in "${FAILED_TAGS[@]}"; do echo " - $tag" done - echo "" - echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway" + echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway" else - echo "✓ All images signed and verified successfully!" + echo "✓ All images signed successfully!" fi shell: bash diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 89ccd7e37..9ba1b5bce 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -122,8 +122,6 @@ export enum ActionsEnum { createOrgDomain = "createOrgDomain", deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", - sendUsageNotification = "sendUsageNotification", - sendTrialNotification = "sendTrialNotification", createRemoteExitNode = "createRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode", getRemoteExitNode = "getRemoteExitNode", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 1aa2a1ef7..0f1914fad 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", { lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); +export const trialNotifications = pgTable("trialNotifications", { + notificationId: serial("notificationId").primaryKey(), + subscriptionId: varchar("subscriptionId", { length: 255 }) + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended + sentAt: bigint("sentAt", { mode: "number" }).notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; export type AlertResources = InferSelectModel; +export type AlertHealthChecks = InferSelectModel; +export type AlertSites = InferSelectModel; +export type AlertRules = InferSelectModel; +export type AlertEmailActions = InferSelectModel; +export type AlertEmailRecipients = InferSelectModel< + typeof alertEmailRecipients +>; +export type AlertWebhookActions = InferSelectModel; +export type TrialNotification = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 25a7b5bf5..05c917887 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -21,6 +21,9 @@ import { targetHealthCheck, users } from "./schema"; +import { serial, varchar } from "drizzle-orm/mysql-core"; +import { pgTable } from "drizzle-orm/pg-core"; +import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", { lastSentAt: integer("lastSentAt") }); +export const trialNotifications = sqliteTable("trialNotifications", { + notificationId: integer("notificationId").primaryKey({ + autoIncrement: true + }), + subscriptionId: text("subscriptionId") + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended + sentAt: integer("sentAt").notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; export type AlertResources = InferSelectModel; +export type AlertHealthChecks = InferSelectModel; +export type AlertSites = InferSelectModel; +export type AlertRule = InferSelectModel; +export type AlertEmailAction = InferSelectModel; +export type AlertEmailRecipient = InferSelectModel; +export type AlertWebhookAction = InferSelectModel; +export type TrialNotification = InferSelectModel; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index bc37271c0..6cb98ba5d 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -30,8 +30,10 @@ import { userOrgRoles, siteProvisioningKeyOrg, siteProvisioningKeys, + alertRules, + targetHealthCheck } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -318,6 +320,14 @@ async function disableFeature( await disableSiteProvisioningKeys(orgId); break; + case TierFeature.AlertingRules: + await disableAlertingRules(orgId); + break; + + case TierFeature.StandaloneHealthChecks: + await disableStandaloneHealthChecks(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise { async function disableSiteProvisioningKeys(orgId: string): Promise { const rows = await db .select({ - siteProvisioningKeyId: - siteProvisioningKeyOrg.siteProvisioningKeyId + siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId }) .from(siteProvisioningKeyOrg) .where(eq(siteProvisioningKeyOrg.orgId, orgId)); @@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise { logger.info(`Disabled password expiration policies for org ${orgId}`); } +async function disableAlertingRules(orgId: string): Promise { + await db + .update(alertRules) + .set({ enabled: false }) + .where(eq(alertRules.orgId, orgId)); + + logger.info(`Disabled all alert rules for org ${orgId}`); +} + +async function disableStandaloneHealthChecks(orgId: string): Promise { + await db + .update(targetHealthCheck) + .set({ hcEnabled: false }) + .where( + and( + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + logger.info(`Disabled standalone health checks for org ${orgId}`); +} + async function disableAutoProvisioning(orgId: string): Promise { // Get all IDP IDs for this org through the idpOrg join table const orgIdps = await db diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index c7a47d1b7..947f28c14 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -174,6 +174,19 @@ export async function handleSubscriptionCreated( // TODO: update user in Sendy } } + + // delete the trial subscrition if we have one + await db + .delete(subscriptions) + .where( + and( + eq( + subscriptions.customerId, + subscription.customer as string + ), + eq(subscriptions.trial, true) + ) + ); } else if (type === "license") { logger.debug( `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index d5dac01e1..820a843f0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -67,24 +67,20 @@ if (build == "saas") { verifyApiKeyIsRoot, certificates.syncCertToNewts ); + + authenticated.post( + `/org/:orgId/send-usage-notification`, + verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine + org.sendUsageNotification + ); + + authenticated.post( + `/org/:orgId/send-trial-notification`, + verifyApiKeyIsRoot, + org.sendTrialNotification + ); } -authenticated.post( - `/org/:orgId/send-usage-notification`, - verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine - verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), - logActionAudit(ActionsEnum.sendUsageNotification), - org.sendUsageNotification -); - -authenticated.post( - `/org/:orgId/send-trial-notification`, - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.sendTrialNotification), - logActionAudit(ActionsEnum.sendTrialNotification), - org.sendTrialNotification -); - authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot,