Skip to content
5 changes: 4 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,10 @@
// The `doriath` segment survives the doriath -> keepiq rename on purpose:
// it is a published contract URL, not an app id. See the class docblock
// on DiscoveryController for the full reasoning.
['name' => 'discovery#document', 'url' => '/api/v1/app/.well-known/doriath', 'verb' => 'GET'],
// Canonical discovery path. The pre-rename path below is still served
// and is retired before the first stable release — see legacyDocument().
['name' => 'discovery#document', 'url' => '/api/v1/app/.well-known/keepiq', 'verb' => 'GET'],
['name' => 'discovery#legacyDocument', 'url' => '/api/v1/app/.well-known/doriath', 'verb' => 'GET'],

// JWT-Bearer token exchange (public; signature-verified).
['name' => 'applicationToken#exchange', 'url' => '/api/v1/token', 'verb' => 'POST'],
Expand Down
21 changes: 21 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@
class Application extends App implements IBootstrap {
public const APP_ID = 'keepiq';

/**
* The app version by which every pre-rename compatibility shim is gone.
*
* The doriath -> keepiq rename left a handful of published identifiers
* carrying the old codename: the assertion audience, the discovery path
* and the envelope format name. Each is accepted or announced in parallel
* with its replacement so no consumer needs a flag day — but only while
* the app is pre-stable. This app has never shipped a stable release, so
* there is no released contract to preserve and no reason to carry a dead
* codename past 1.0.0; the shims are removed before the first stable
* release, not deferred to a future apiVersion.
*
* `apiVersion` therefore stays at 1 throughout. The "breaking changes
* MUST ship as a new apiVersion" rule in the secret-store-api spec binds
* from the first stable release onward, which is exactly the point these
* shims stop existing.
*
* @var string
*/
public const PRE_STABLE_COMPAT_REMOVED_IN = '1.0.0';
Comment thread
WilcoLouwerse marked this conversation as resolved.

/**
* Constructor for the Application class.
*
Expand Down
120 changes: 109 additions & 11 deletions lib/Controller/DiscoveryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,27 @@
* Keepiq Machine API Discovery Controller
*
* Serves the unauthenticated, machine-readable discovery document at
* `GET /api/v1/app/.well-known/doriath`. A consumer configures one base
* `GET /api/v1/app/.well-known/keepiq`, and at the pre-rename
* `.well-known/doriath` until that path is retired. A consumer configures one base
* URL plus its application id and private key, fetches this document, and
* derives every contract URL (token endpoint, grant type, assertion
* requirements, secret endpoints, envelope formats) without reading
* Keepiq source. The document carries no instance-private data.
*
* THE PATH SEGMENT STILL SAYS `doriath` AFTER THE doriath -> keepiq RENAME,
* on purpose. It is the one URL a machine consumer is configured with by
* hand; everything else it uses is derived from the document this endpoint
* returns. Renaming the segment would break every configured consumer at the
* same moment as, and independently of, the `/apps/<id>/` prefix change —
* two breaking changes where the contract (openspec/specs/secret-store-api/
* spec.md) allows none in place. Moving it belongs to the coordinated
* apiVersion bump that also retires the `doriath-machine-secret-v1` envelope
* name and the `aud=doriath` claim, not to an app-id rename.
* BOTH PATHS ARE SERVED, and the pre-rename one is not going away yet. This
* is the one URL a machine consumer is configured with by hand — everything
* else it uses is derived from the document this endpoint returns — so moving
* it would break every configured consumer at once. Serving both instead is
* additive: the document names the canonical path in `discoveryPath`, so a
* consumer re-points itself without anyone coordinating a change window, and
* `deprecatedDiscoveryPaths[].removedInAppVersion` says when the old one
* stops. Every hit on it is logged so the migration is observable.
*
* The old path retires at Application::PRE_STABLE_COMPAT_REMOVED_IN, together
* with the `doriath-machine-secret-v1` envelope name and the `aud=doriath`
* claim — not at a future apiVersion. Nothing stable has shipped, so there is
* no released contract a version bump would protect; PreStableCompatDeadlineTest
* fails the build if any of the three outlives that version.
*
* @category Controller
* @package OCA\Keepiq\Controller
Expand All @@ -37,6 +43,7 @@
namespace OCA\Keepiq\Controller;

use OCA\Keepiq\AppInfo\Application as KeepiqApp;
use OCA\Keepiq\Service\AudiencePolicy;
use OCA\Keepiq\Service\JwtAuthService;
use OCA\Keepiq\Service\MachineSecretEnvelopeService;
use OCP\AppFramework\Controller;
Expand All @@ -46,6 +53,7 @@
use OCP\AppFramework\Http\JSONResponse;
use OCP\IAppConfig;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
use OCP\IURLGenerator;

/**
Expand All @@ -62,19 +70,42 @@ class DiscoveryController extends Controller {
*/
public const API_VERSION = 1;

/**
* The discovery path this document advertises as canonical.
*
* @var string
*/
public const CANONICAL_DISCOVERY_PATH = '/api/v1/app/.well-known/keepiq';
Comment thread
WilcoLouwerse marked this conversation as resolved.

/**
* The pre-rename discovery path, still served and now deprecated.
*
* @var string
*/
public const DEPRECATED_DISCOVERY_PATH = '/api/v1/app/.well-known/doriath';

/**
* The app version in which DEPRECATED_DISCOVERY_PATH stops being served.
*
* @var string
*/
public const DEPRECATED_PATH_REMOVED_IN = KeepiqApp::PRE_STABLE_COMPAT_REMOVED_IN;

/**
* Constructor for DiscoveryController.
*
* @param IRequest $request The HTTP request
* @param IURLGenerator $urlGenerator The URL generator
* @param IAppConfig|null $appConfig The app config (lease policy advert)
* @param LoggerInterface|null $logger Logger for the deprecated-path warning
*
* @return void
*/
public function __construct(
IRequest $request,
private IURLGenerator $urlGenerator,
private ?IAppConfig $appConfig = null,
private ?LoggerInterface $logger = null,
) {
parent::__construct(appName: KeepiqApp::APP_ID, request: $request);
}//end __construct()
Expand Down Expand Up @@ -107,12 +138,35 @@ public function document(): JSONResponse {
return new JSONResponse(
data: [
'apiVersion' => self::API_VERSION,
// The path to fetch this document from. A consumer configured
// with the pre-rename path can re-point itself from here
// before that path is retired.
'discoveryPath' => self::CANONICAL_DISCOVERY_PATH,
'deprecatedDiscoveryPaths' => [
[
'value' => self::DEPRECATED_DISCOVERY_PATH,
'removedInAppVersion' => self::DEPRECATED_PATH_REMOVED_IN,
],
],
'tokenEndpoint' => $tokenEndpoint,
'grantType' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => [
'alg' => 'RS256',
'maxLifetime' => JwtAuthService::ACCESS_TOKEN_TTL,
'audience' => JwtAuthService::EXPECTED_AUDIENCE,
// `audience` is the value to SEND; `acceptedAudiences` is
// what this instance will honour. Both are additive within
// the current apiVersion: a consumer reading `audience`
// converges on the canonical name, and one still sending a
// deprecated value keeps working until the version named in
// `deprecatedAudiences[].removedInAppVersion`.
'audience' => AudiencePolicy::CANONICAL_AUDIENCE,
Comment thread
WilcoLouwerse marked this conversation as resolved.
'acceptedAudiences' => AudiencePolicy::ACCEPTED_AUDIENCES,
'deprecatedAudiences' => [
[
'value' => AudiencePolicy::DEPRECATED_AUDIENCE,
'removedInAppVersion' => AudiencePolicy::DEPRECATED_AUDIENCE_REMOVED_IN,
],
],
'audienceUrl' => $tokenAbsolute,
],
'secrets' => [
Expand All @@ -122,7 +176,18 @@ public function document(): JSONResponse {
'create' => $this->urlGenerator->linkToRoute('keepiq.applicationSecrets.index'),
'update' => $this->urlGenerator->linkToRoute('keepiq.applicationSecrets.index') . '/{id}',
],
// What this instance actually emits today. The successor is
// announced separately rather than listed here, because
// listing a format nothing writes would be a lie a consumer
// could reasonably act on.
'envelopeFormats' => [MachineSecretEnvelopeService::FORMAT],
'upcomingEnvelopeFormats' => [
[
'value' => MachineSecretEnvelopeService::UPCOMING_FORMAT,
'replaces' => MachineSecretEnvelopeService::FORMAT,
'emittedFromAppVersion' => MachineSecretEnvelopeService::UPCOMING_FORMAT_APP_VERSION,
],
],
// Machine leases (machine-secret-leases §3.3): additive
// advert of the instance lease policy — no envelope or
// addressing change.
Expand All @@ -135,4 +200,37 @@ public function document(): JSONResponse {
]
);
}//end document()
/**
* Serve the same document on the pre-rename discovery path.
*
* This is THE one URL a machine consumer is configured with by hand, so
* moving it is the single most disruptive rename available: everything
* else a consumer uses is derived from the document this returns. Serving
* both paths is additive and costs nothing, and it hands the consumer the
* canonical path in `discoveryPath` so it can re-point itself without
* anyone coordinating a change window.
*
* Each hit is logged so the set of consumers still on the old path is
* observable before the shim is removed.
*
* @return JSONResponse The discovery document.
*
* @spec openspec/specs/secret-store-api/spec.md
*/
#[PublicPage]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 120, period: 60)]
public function legacyDocument(): JSONResponse {
$this->logger?->warning(
'Discovery fetched on the deprecated path "{deprecated}", which is removed in '
. 'app version {version}. Re-point the consumer at "{canonical}".',
[
'deprecated' => self::DEPRECATED_DISCOVERY_PATH,
'canonical' => self::CANONICAL_DISCOVERY_PATH,
'version' => self::DEPRECATED_PATH_REMOVED_IN,
]
);

return $this->document();
}//end legacyDocument()
}//end class
Loading
Loading