Skip to content

Commit b53aaa7

Browse files
authored
Portal procurement: enterprise licence-key mechanism (generate at trial, upgrade on subscription, view/download) (Stirling-Tools#6902)
Builds on the procurement vertical slice (Stirling-Tools#6861). Adds the **enterprise licence-key mechanism**: a Keygen licence is generated at trial, upgraded in place when the committed subscription is created, and is viewable/downloadable in the portal. Flag-gated — ships with the mock as default until Keygen env vars are wired. ### What it does - **Offline / air-gapped licence** is a new **priced add-on** on the quote ($12k/yr, flat, alongside indemnification / training / QBR). - **Licence key visible from the trial step** — the portal shows the key with **Copy**, and (when the offline add-on is bought) a **Download offline licence (.lic)** button. - **Real Keygen client, called directly from Java** (`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`; `MockEnterpriseLicenseService` stays the default. All creds are env vars (`STIRLING_KEYGEN_*`) — nothing committed. - **Provisioning is driven by the Stripe `customer.subscription.created` event** (source of truth), not a UI action — so a sales-led deal entered manually in Stripe provisions a licence too. The webhook calls a new admin `POST /api/v1/procurement/provision`, which upgrades the trial licence **in place** to the committed annual term, **valid immediately** (no wait for payment). The deal stays in the payment step so the outstanding invoice remains visible. - Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted) so the self-hosted `KeygenLicenseVerifier` validates it fully offline. ### Not in scope (deliberate, follow-ups) - Cloud entitlement flip — a **cloud** customer sees/downloads the key but the running cloud product doesn't unlock yet (self-hosted/air-gap **are** unlocked by the key). Immediate next PR. - `invoice.paid → fully-live` / `payment_failed → suspend` webhook safety-net. ### Companion change (separate repo) - The Stripe-webhook wiring that calls `/provision` lives in the **Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR): `stripe-webhook` routes enterprise-committed `subscription.created` → `provisionProcurement()` → the Java admin endpoint. ### Prod setup required - Create the committed-enterprise **Keygen policy with `scheme=ed25519`** under the existing account, set `STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars. ### Verified saas `:saas:test` (procurement) · portal typecheck / eslint / prettier · 82 portal tests · `deno check` on the webhook handler. ### Review follow-ups (PR review, tracked) Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies out of thrown/logged messages; fail-fast at startup when the flag is on but creds are missing; gate the offline `.lic` on the *accepted* quote (not the latest draft). Deliberately deferred, tracked here: - **Pre-flag verification.** Before `stirling.keygen.enabled=true`, confirm the id-vs-key addressing against live Keygen. (The shipping self-hosted edge addresses licences by URL-safe key in the path and Keygen docs allow it, so the client mirrors that — but confirm empirically with the real committed-enterprise policy.) - **No auto-revoke on non-payment.** Provision issues an immediately-valid annual licence before payment settles; `invoice.paid → live` and `payment_failed → suspend` are out of scope here. Note the offline `.lic`, once downloaded, verifies offline for the full term and **can't be revoked** — so the real mitigation for the offline case is a shorter bridge term until `invoice.paid`, not just wiring `suspend`. Enterprise is sales-led/ADMIN-gated, so this is a collections concern, not mass abuse.
1 parent 3fa0f30 commit b53aaa7

25 files changed

Lines changed: 1121 additions & 184 deletions

app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
import java.util.List;
44
import java.util.Objects;
5+
import java.util.Optional;
56

67
import org.springframework.context.annotation.Profile;
8+
import org.springframework.http.HttpHeaders;
79
import org.springframework.http.HttpStatus;
10+
import org.springframework.http.MediaType;
811
import org.springframework.http.ResponseEntity;
912
import org.springframework.security.access.prepost.PreAuthorize;
1013
import org.springframework.security.core.Authentication;
1114
import org.springframework.web.bind.annotation.GetMapping;
1215
import org.springframework.web.bind.annotation.PostMapping;
1316
import org.springframework.web.bind.annotation.RequestBody;
1417
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RequestParam;
1519
import org.springframework.web.bind.annotation.RestController;
1620

1721
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -72,24 +76,28 @@ public ProcurementController(
7276
public record QuoteRequest(
7377
long volume,
7478
int users,
79+
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
7580
String deployment,
7681
int termYears,
7782
String serviceLevel,
7883
boolean indemnification,
7984
boolean training,
8085
boolean qbr,
86+
boolean offlineLicense,
8187
String currency,
8288
String businessName) {
8389
QuoteConfig toConfig() {
8490
return new QuoteConfig(
8591
volume,
8692
users,
93+
intensity,
8794
deployment,
8895
termYears,
8996
serviceLevel,
9097
indemnification,
9198
training,
9299
qbr,
100+
offlineLicense,
93101
currency);
94102
}
95103
}
@@ -115,12 +123,14 @@ public record QuoteResponse(
115123
public record QuoteConfigEcho(
116124
long volume,
117125
int users,
126+
int intensity,
118127
String deployment,
119128
int termYears,
120129
String serviceLevel,
121130
boolean indemnification,
122131
boolean training,
123132
boolean qbr,
133+
boolean offlineLicense,
124134
String currency,
125135
String businessName) {}
126136

@@ -131,6 +141,7 @@ public record SnapshotResponse(
131141
String trialEndsAt,
132142
int trialExtensionsUsed,
133143
boolean licensed,
144+
String licenseKey,
134145
QuoteResponse latestQuote) {}
135146

136147
// ---- endpoints ----------------------------------------------------------
@@ -143,21 +154,49 @@ public record SnapshotResponse(
143154
@GetMapping
144155
@PreAuthorize("isAuthenticated()")
145156
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
146-
Long teamId = resolveTeam(auth);
147-
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
157+
Optional<TeamMembership> membership = primaryMembership(auth);
158+
if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
159+
Long teamId = membership.get().getTeam().getId();
160+
// The licence key is the team's secret entitlement — leader-only. Members still see the
161+
// journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated.
162+
boolean leader = membership.get().getRole() == TeamRole.LEADER;
148163
return ResponseEntity.ok(
149-
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
164+
procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT));
150165
}
151166

152167
private static final SnapshotResponse EMPTY_SNAPSHOT =
153-
new SnapshotResponse(null, null, null, null, 0, false, null);
168+
new SnapshotResponse(null, null, null, null, 0, false, null, null);
169+
170+
/**
171+
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
172+
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
173+
* that a licence exists to a team without the add-on.
174+
*/
175+
@GetMapping("/license/file")
176+
@PreAuthorize("isAuthenticated()")
177+
public ResponseEntity<String> licenseFile(Authentication auth) {
178+
// Leader-only: the offline .lic is the team's portable entitlement, not a member artefact.
179+
Long teamId = requireLeader(auth);
180+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
181+
return procurement
182+
.offlineLicenseFile(teamId)
183+
.<ResponseEntity<String>>map(
184+
cert ->
185+
ResponseEntity.ok()
186+
.header(
187+
HttpHeaders.CONTENT_DISPOSITION,
188+
"attachment; filename=\"stirling-enterprise.lic\"")
189+
.contentType(MediaType.TEXT_PLAIN)
190+
.body(cert))
191+
.orElseGet(() -> ResponseEntity.notFound().build());
192+
}
154193

155194
@PostMapping("/trial/start")
156195
@PreAuthorize("isAuthenticated()")
157196
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
158197
Long teamId = requireLeader(auth);
159198
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
160-
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
199+
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
161200
}
162201

163202
@PostMapping("/trial/extend")
@@ -166,7 +205,7 @@ public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
166205
Long teamId = requireLeader(auth);
167206
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
168207
try {
169-
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
208+
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true));
170209
} catch (IllegalStateException e) {
171210
return ResponseEntity.status(HttpStatus.CONFLICT).build();
172211
}
@@ -197,8 +236,26 @@ public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
197236
Long teamId = requireLeader(auth);
198237
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
199238
try {
200-
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
239+
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true));
240+
} catch (IllegalStateException e) {
241+
return ResponseEntity.status(HttpStatus.CONFLICT).build();
242+
}
243+
}
244+
245+
/**
246+
* Provision on accept: upgrade the team's licence to the committed annual term, valid
247+
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
248+
* the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal
249+
* stays in the payment step until the invoice settles. Idempotent.
250+
*/
251+
@PostMapping("/provision")
252+
@PreAuthorize("hasRole('ADMIN')")
253+
public ResponseEntity<Void> provision(@RequestParam("teamId") long teamId) {
254+
try {
255+
procurement.provisionLicense(teamId);
256+
return ResponseEntity.ok().build();
201257
} catch (IllegalStateException e) {
258+
log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage());
202259
return ResponseEntity.status(HttpStatus.CONFLICT).build();
203260
}
204261
}
@@ -214,7 +271,7 @@ public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
214271
Long teamId = requireLeader(auth);
215272
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
216273
try {
217-
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
274+
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true));
218275
} catch (IllegalStateException e) {
219276
return ResponseEntity.status(HttpStatus.CONFLICT).build();
220277
}
@@ -233,34 +290,31 @@ public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
233290

234291
// ---- helpers ------------------------------------------------------------
235292

236-
/**
237-
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
238-
*/
239-
private Long resolveTeam(Authentication auth) {
293+
/** The caller's primary team membership; empty when unauthenticated/teamless. */
294+
private Optional<TeamMembership> primaryMembership(Authentication auth) {
240295
User user;
241296
try {
242297
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
243298
} catch (SecurityException e) {
244-
return null;
299+
return Optional.empty();
245300
}
246-
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
247-
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
301+
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
248302
}
249303

250304
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
251305
private Long requireLeader(Authentication auth) {
252-
User user;
253-
try {
254-
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
255-
} catch (SecurityException e) {
256-
return null;
257-
}
258-
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
259-
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
260-
return rows.get(0).getTeam().getId();
306+
return primaryMembership(auth)
307+
.filter(m -> m.getRole() == TeamRole.LEADER)
308+
.map(m -> m.getTeam().getId())
309+
.orElse(null);
261310
}
262311

263-
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
312+
/**
313+
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
314+
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
315+
* endpoints are leader-gated, so they always pass true.
316+
*/
317+
private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) {
264318
QuoteResponse latest =
265319
procurement.quotesForDeal(deal.getDealId()).stream()
266320
.findFirst()
@@ -273,6 +327,7 @@ private SnapshotResponse toSnapshot(ProcurementDeal deal) {
273327
str(deal.getTrialEndsAt()),
274328
deal.getTrialExtensionsUsed(),
275329
deal.getLicenseRef() != null,
330+
includeLicenseKey ? deal.getLicenseRef() : null,
276331
latest);
277332
}
278333

@@ -291,12 +346,14 @@ private QuoteResponse toQuote(ProcurementQuote q) {
291346
new QuoteConfigEcho(
292347
q.getVolume(),
293348
0,
349+
q.getIntensity(),
294350
q.getDeployment(),
295351
q.getTermYears(),
296352
q.getServiceLevel(),
297353
q.isIndemnification(),
298354
q.isTraining(),
299355
q.isQbr(),
356+
q.isOfflineLicense(),
300357
q.getCurrency(),
301358
q.getBusinessName()));
302359
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package stirling.software.saas.procurement.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.context.annotation.Profile;
5+
import org.springframework.stereotype.Component;
6+
7+
import lombok.Getter;
8+
import lombok.Setter;
9+
10+
/**
11+
* Keygen credentials + policy for issuing enterprise procurement licences directly from Java.
12+
* Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code
13+
* STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed.
14+
*
15+
* <p>{@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the
16+
* real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are
17+
* wired.
18+
*/
19+
@Getter
20+
@Setter
21+
@Component
22+
@Profile("saas")
23+
@ConfigurationProperties(prefix = "stirling.keygen")
24+
public class KeygenConfigurationProperties {
25+
26+
/** Master switch: when true, the real Keygen client replaces the mock licence service. */
27+
private boolean enabled = false;
28+
29+
/** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */
30+
private String accountId;
31+
32+
/** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */
33+
private String apiToken;
34+
35+
/** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */
36+
private String policyId;
37+
38+
/** API base; overridable for self-hosted Keygen, defaults to the hosted service. */
39+
private String apiBase = "https://api.keygen.sh/v1";
40+
41+
/**
42+
* License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code
43+
* KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline.
44+
*/
45+
private String licenseFileAlgorithm = "base64+ed25519";
46+
47+
/** True when the credentials needed to talk to Keygen are all present. */
48+
public boolean isConfigured() {
49+
return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId);
50+
}
51+
52+
private static boolean notBlank(String s) {
53+
return s != null && !s.isBlank();
54+
}
55+
}

app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,37 @@
1111
*/
1212
public interface EnterpriseLicenseService {
1313

14-
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
15-
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
14+
/**
15+
* Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader);
16+
* returns the licence reference (the Keygen key, stored on the deal).
17+
*/
18+
String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt);
1619

1720
/** Move a licence's expiry out (trial extension). */
1821
void extendLicense(String licenseRef, LocalDateTime newExpiry);
1922

20-
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
21-
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
23+
/**
24+
* Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements}
25+
* ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already
26+
* has a trial licence), that licence is upgraded in place so the key the buyer already holds
27+
* keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the
28+
* licence reference.
29+
*/
30+
String issueAnnualLicense(
31+
Long teamId,
32+
String ownerEmail,
33+
LocalDateTime expiresAt,
34+
String existingRef,
35+
LicenseEntitlements entitlements);
2236

2337
/** Suspend a licence (e.g. payment failed, deal lost). */
2438
void suspendLicense(String licenseRef);
39+
40+
/**
41+
* Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----}
42+
* certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline
43+
* add-on gates whether this is offered; the certificate itself is generated on demand and never
44+
* stored.
45+
*/
46+
String checkOutLicenseFile(String licenseRef);
2547
}

0 commit comments

Comments
 (0)