mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
refactor(insights): manager-only Create endpoint + native categories array
Insights are produced by internal Appwrite services (edge, executor, background analyzers) — never by user clients. Move the ingestion endpoint accordingly. - Move Http/Insights/Create.php → Http/Manager/Insights/Create.php. - Path: /v1/insights → /v1/manager/insights. SDK Method marked `hide: true` and namespaced under `manager` so generated SDKs don't expose it. Auth narrowed from [ADMIN, KEY] to [KEY] only. - New scope `insights.manager`. Not granted by any user role (app/config/roles.php) — Cloud/edge teams configure their internal key issuance to grant it. `insights.write` description trimmed to the user-facing surface (update/dismiss/delete) since create is now manager-only. - Reports, ListInsights, GetInsight, UpdateInsight, DeleteInsight remain at /v1/insights/*. Existing scopes unchanged. - Reports `categories` switched from JSON-encoded string to a native array<string> column (size 64 per entry, up to 32 entries via the endpoint validator). MySQL JSON-array indexes are weak and we never query individual entries — read+rewrite only. - E2E test API key in tests/e2e/Scopes/ProjectCustom.php gains insights.read/write/manager + reports.read/write so the manager endpoint is reachable from the test harness. - E2E InsightsBase.createInsight() helper now POSTs /manager/insights. - New testCreateRequiresManagerScope verifies a key with insights.read/write but no insights.manager is rejected with 401. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a1f64c6f71
commit
4fc3e9c386
@@ -2022,16 +2022,18 @@ $platformCollections = [
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
// JSON array of category strings, e.g. ['performance', 'accessibility'].
|
||||
// Category strings, e.g. 'performance', 'accessibility'. Native array
|
||||
// column — we never query on individual entries (MySQL JSON-array
|
||||
// indexes are weak), this is read+rewrite only.
|
||||
'$id' => ID::custom('categories'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 2048,
|
||||
'size' => 64,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => ['json'],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('analyzedAt'),
|
||||
|
||||
@@ -344,7 +344,11 @@ return [
|
||||
'category' => 'Other',
|
||||
],
|
||||
'insights.write' => [
|
||||
'description' => 'Access to create, update, dismiss, and delete insights.',
|
||||
'description' => 'Access to update, dismiss, and delete insights.',
|
||||
'category' => 'Other',
|
||||
],
|
||||
'insights.manager' => [
|
||||
'description' => 'Internal-only: ingest insights produced by Appwrite analyzers (edge, executor, …). Not granted to user roles.',
|
||||
'category' => 'Other',
|
||||
],
|
||||
|
||||
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
|
||||
namespace Appwrite\Platform\Modules\Insights\Http\Manager\Insights;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
@@ -23,6 +23,15 @@ use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
/**
|
||||
* Manager-only endpoint for analyzer ingestion.
|
||||
*
|
||||
* Insights are produced by internal Appwrite services (edge, executor,
|
||||
* background analyzers) — never by user clients. The endpoint lives under
|
||||
* /v1/manager/* and is hidden from generated SDKs to keep that contract
|
||||
* explicit. Internal services call it directly over HTTP using a server
|
||||
* API key with the `insights.manager` scope.
|
||||
*/
|
||||
class Create extends Action
|
||||
{
|
||||
use HTTP;
|
||||
@@ -36,10 +45,10 @@ class Create extends Action
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
|
||||
->setHttpPath('/v1/insights')
|
||||
->setHttpPath('/v1/manager/insights')
|
||||
->desc('Create insight')
|
||||
->groups(['api', 'insights'])
|
||||
->label('scope', 'insights.write')
|
||||
->groups(['api', 'manager', 'insights'])
|
||||
->label('scope', 'insights.manager')
|
||||
->label('event', 'insights.[insightId].create')
|
||||
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
|
||||
->label('audits.event', 'insight.create')
|
||||
@@ -48,19 +57,20 @@ class Create extends Action
|
||||
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
|
||||
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
|
||||
->label('sdk', new Method(
|
||||
namespace: 'insights',
|
||||
namespace: 'manager',
|
||||
group: 'insights',
|
||||
name: 'create',
|
||||
name: 'createInsight',
|
||||
description: <<<EOT
|
||||
Create a new insight. Server-side only: insights are produced by analyzers and surfaced to project members.
|
||||
Manager-only: ingest an insight produced by an internal analyzer (edge, executor, background worker, …). Not exposed to user-facing client or server SDKs.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
auth: [AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_CREATED,
|
||||
model: Response::MODEL_INSIGHT,
|
||||
),
|
||||
]
|
||||
],
|
||||
hide: true,
|
||||
))
|
||||
->param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform'])
|
||||
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID. Optional — leave empty for ad-hoc insights not attached to a report.', true, ['dbForPlatform'])
|
||||
@@ -72,7 +82,7 @@ class Create extends Action
|
||||
->param('title', '', new Text(256), 'Short, human-readable title.')
|
||||
->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true)
|
||||
->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true)
|
||||
->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true)
|
||||
->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `service`, `method`, and an optional `params` object.', true)
|
||||
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace Appwrite\Platform\Modules\Insights\Services;
|
||||
|
||||
use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Manager\Insights\Create as CreateInsight;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Reports\Create as CreateReport;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Reports\Delete as DeleteReport;
|
||||
use Appwrite\Platform\Modules\Insights\Http\Reports\Get as GetReport;
|
||||
@@ -26,7 +26,9 @@ class Http extends Service
|
||||
$this->addAction(UpdateReport::getName(), new UpdateReport());
|
||||
$this->addAction(DeleteReport::getName(), new DeleteReport());
|
||||
|
||||
// Manager-only ingestion (hidden from SDKs, /v1/manager/insights).
|
||||
$this->addAction(CreateInsight::getName(), new CreateInsight());
|
||||
|
||||
$this->addAction(GetInsight::getName(), new GetInsight());
|
||||
$this->addAction(ListInsights::getName(), new ListInsights());
|
||||
$this->addAction(UpdateInsight::getName(), new UpdateInsight());
|
||||
|
||||
@@ -177,6 +177,11 @@ trait ProjectCustom
|
||||
'policies.write',
|
||||
'templates.read',
|
||||
'templates.write',
|
||||
'insights.read',
|
||||
'insights.write',
|
||||
'insights.manager',
|
||||
'reports.read',
|
||||
'reports.write',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ trait InsightsBase
|
||||
|
||||
protected function createInsight(array $body, array $headers = null): array
|
||||
{
|
||||
return $this->client->call(Client::METHOD_POST, '/insights', $headers ?? $this->serverHeaders(), $body);
|
||||
// Manager-only endpoint — internal Appwrite services ingest here, not user SDKs.
|
||||
return $this->client->call(Client::METHOD_POST, '/manager/insights', $headers ?? $this->serverHeaders(), $body);
|
||||
}
|
||||
|
||||
protected function getInsight(string $insightId, array $headers = null): array
|
||||
@@ -799,6 +800,25 @@ trait InsightsBase
|
||||
$this->assertSame(401, $unauthorized['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testCreateRequiresManagerScope(): void
|
||||
{
|
||||
// A server key with insights.read + insights.write but NOT insights.manager
|
||||
// must be rejected — Create lives behind /v1/manager/insights and only
|
||||
// internal Appwrite services hold the manager scope.
|
||||
$userKey = $this->getNewKey([
|
||||
'insights.read',
|
||||
'insights.write',
|
||||
]);
|
||||
|
||||
$rejected = $this->createInsight($this->sampleInsight(), [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $userKey,
|
||||
]);
|
||||
|
||||
$this->assertSame(401, $rejected['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testListSurvivesEmptyDatabase(): void
|
||||
{
|
||||
$list = $this->listInsights([
|
||||
|
||||
Reference in New Issue
Block a user