diff --git a/ee/apps/den-api/src/routes/org/plugin-system/access.ts b/ee/apps/den-api/src/routes/org/plugin-system/access.ts index 8886d6c0..7dd6efd7 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/access.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/access.ts @@ -4,6 +4,9 @@ import { ConfigObjectTable, ConnectorInstanceAccessGrantTable, ConnectorInstanceTable, + MarketplaceAccessGrantTable, + MarketplacePluginTable, + MarketplaceTable, PluginAccessGrantTable, PluginConfigObjectTable, PluginTable, @@ -12,9 +15,9 @@ import type { MemberTeamSummary, OrganizationContext } from "../../../orgs.js" import { db } from "../../../db.js" import { memberHasRole } from "../shared.js" -export type PluginArchResourceKind = "config_object" | "connector_instance" | "plugin" +export type PluginArchResourceKind = "config_object" | "connector_instance" | "marketplace" | "plugin" export type PluginArchRole = "viewer" | "editor" | "manager" -export type PluginArchCapability = "config_object.create" | "connector_account.create" | "connector_instance.create" | "plugin.create" +export type PluginArchCapability = "config_object.create" | "connector_account.create" | "connector_instance.create" | "marketplace.create" | "plugin.create" export type PluginArchActorContext = { memberTeams: MemberTeamSummary[] @@ -24,12 +27,20 @@ export type PluginArchActorContext = { type MemberId = OrganizationContext["currentMember"]["id"] type TeamId = MemberTeamSummary["id"] type ConfigObjectId = typeof ConfigObjectTable.$inferSelect.id +type MarketplaceId = typeof MarketplaceTable.$inferSelect.id type PluginId = typeof PluginTable.$inferSelect.id type ConnectorInstanceId = typeof ConnectorInstanceTable.$inferSelect.id type ConfigObjectGrantRow = Pick +type MarketplaceGrantRow = Pick type PluginGrantRow = Pick type ConnectorInstanceGrantRow = Pick -type GrantRow = ConfigObjectGrantRow | PluginGrantRow | ConnectorInstanceGrantRow +type GrantRow = ConfigObjectGrantRow | MarketplaceGrantRow | PluginGrantRow | ConnectorInstanceGrantRow + +type MarketplaceResourceLookupInput = { + context: PluginArchActorContext + resourceId: MarketplaceId + resourceKind: "marketplace" +} type PluginResourceLookupInput = { context: PluginArchActorContext @@ -52,6 +63,7 @@ type ConfigObjectResourceLookupInput = { type ResourceLookupInput = | PluginResourceLookupInput | ConnectorInstanceResourceLookupInput + | MarketplaceResourceLookupInput | ConfigObjectResourceLookupInput type RequireResourceRoleInput = ResourceLookupInput & { role: PluginArchRole } @@ -144,11 +156,48 @@ async function resolvePluginRoleForIds(context: PluginArchActorContext, pluginId return resolveGrantRole({ context, grants }) } +async function resolveMarketplaceRoleForIds(context: PluginArchActorContext, marketplaceIds: MarketplaceId[]) { + if (marketplaceIds.length === 0) { + return null + } + + if (isPluginArchOrgAdmin(context)) { + return "manager" satisfies PluginArchRole + } + + const grants = await db + .select({ + orgMembershipId: MarketplaceAccessGrantTable.orgMembershipId, + orgWide: MarketplaceAccessGrantTable.orgWide, + removedAt: MarketplaceAccessGrantTable.removedAt, + role: MarketplaceAccessGrantTable.role, + teamId: MarketplaceAccessGrantTable.teamId, + }) + .from(MarketplaceAccessGrantTable) + .where(inArray(MarketplaceAccessGrantTable.marketplaceId, marketplaceIds)) + + return resolveGrantRole({ context, grants }) +} + export async function resolvePluginArchResourceRole(input: ResourceLookupInput) { if (isPluginArchOrgAdmin(input.context)) { return "manager" satisfies PluginArchRole } + if (input.resourceKind === "marketplace") { + const grants = await db + .select({ + orgMembershipId: MarketplaceAccessGrantTable.orgMembershipId, + orgWide: MarketplaceAccessGrantTable.orgWide, + removedAt: MarketplaceAccessGrantTable.removedAt, + role: MarketplaceAccessGrantTable.role, + teamId: MarketplaceAccessGrantTable.teamId, + }) + .from(MarketplaceAccessGrantTable) + .where(eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId)) + return resolveGrantRole({ context: input.context, grants }) + } + if (input.resourceKind === "plugin") { const grants = await db .select({ @@ -160,7 +209,18 @@ export async function resolvePluginArchResourceRole(input: ResourceLookupInput) }) .from(PluginAccessGrantTable) .where(eq(PluginAccessGrantTable.pluginId, input.resourceId)) - return resolveGrantRole({ context: input.context, grants }) + const resolved = await resolveGrantRole({ context: input.context, grants }) + if (resolved) { + return resolved + } + + const memberships = await db + .select({ marketplaceId: MarketplacePluginTable.marketplaceId }) + .from(MarketplacePluginTable) + .where(and(eq(MarketplacePluginTable.pluginId, input.resourceId), isNull(MarketplacePluginTable.removedAt))) + + const marketplaceRole = await resolveMarketplaceRoleForIds(input.context, memberships.map((membership) => membership.marketplaceId)) + return maxRole(resolved, marketplaceRole ? "viewer" : null) } if (input.resourceKind === "connector_instance") { @@ -213,7 +273,7 @@ export async function requirePluginArchCapability(context: PluginArchActorContex export async function requirePluginArchResourceRole(input: { context: PluginArchActorContext - resourceId: ConfigObjectId | ConnectorInstanceId | PluginId + resourceId: ConfigObjectId | ConnectorInstanceId | MarketplaceId | PluginId resourceKind: PluginArchResourceKind role: PluginArchRole }) { diff --git a/ee/apps/den-api/src/routes/org/plugin-system/contracts.ts b/ee/apps/den-api/src/routes/org/plugin-system/contracts.ts index 33753ea8..92295b39 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/contracts.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/contracts.ts @@ -61,6 +61,18 @@ import { githubWebhookIgnoredResponseSchema, githubWebhookRawBodySchema, githubWebhookUnauthorizedResponseSchema, + marketplaceAccessGrantParamsSchema, + marketplaceCreateSchema, + marketplaceDetailResponseSchema, + marketplaceListQuerySchema, + marketplaceListResponseSchema, + marketplaceMutationResponseSchema, + marketplaceParamsSchema, + marketplacePluginListResponseSchema, + marketplacePluginMutationResponseSchema, + marketplacePluginParamsSchema, + marketplacePluginWriteSchema, + marketplaceUpdateSchema, pluginAccessGrantParamsSchema, pluginConfigObjectParamsSchema, pluginCreateSchema, @@ -79,7 +91,7 @@ import { orgIdParamSchema } from "../shared.js" type EndpointMethod = "DELETE" | "GET" | "PATCH" | "POST" type EndpointAudience = "admin" | "public_webhook" -type EndpointTag = "Config Objects" | "Plugins" | "Connectors" | "GitHub" | "Webhooks" +type EndpointTag = "Config Objects" | "Plugins" | "Marketplaces" | "Connectors" | "GitHub" | "Webhooks" type EndpointContract = { audience: EndpointAudience @@ -139,6 +151,14 @@ export const pluginArchRoutePaths = { pluginReleases: `${orgBasePath}/plugins/:pluginId/releases`, pluginAccess: `${orgBasePath}/plugins/:pluginId/access`, pluginAccessGrant: `${orgBasePath}/plugins/:pluginId/access/:grantId`, + marketplaces: `${orgBasePath}/marketplaces`, + marketplace: `${orgBasePath}/marketplaces/:marketplaceId`, + marketplaceArchive: `${orgBasePath}/marketplaces/:marketplaceId/archive`, + marketplaceRestore: `${orgBasePath}/marketplaces/:marketplaceId/restore`, + marketplacePlugins: `${orgBasePath}/marketplaces/:marketplaceId/plugins`, + marketplacePlugin: `${orgBasePath}/marketplaces/:marketplaceId/plugins/:pluginId`, + marketplaceAccess: `${orgBasePath}/marketplaces/:marketplaceId/access`, + marketplaceAccessGrant: `${orgBasePath}/marketplaces/:marketplaceId/access/:grantId`, connectorAccounts: `${orgBasePath}/connector-accounts`, connectorAccount: `${orgBasePath}/connector-accounts/:connectorAccountId`, connectorAccountDisconnect: `${orgBasePath}/connector-accounts/:connectorAccountId/disconnect`, @@ -427,6 +447,114 @@ export const pluginArchEndpointContracts: Record = { response: { description: "Plugin access grant revoked successfully.", status: 204 }, tag: "Plugins", }, + listMarketplaces: { + audience: "admin", + description: "List accessible marketplaces for the organization.", + method: "GET", + path: pluginArchRoutePaths.marketplaces, + request: { params: orgIdParamSchema, query: marketplaceListQuerySchema }, + response: { description: "Marketplace list.", schema: marketplaceListResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + getMarketplace: { + audience: "admin", + description: "Get one marketplace and its current metadata.", + method: "GET", + path: pluginArchRoutePaths.marketplace, + request: { params: marketplaceParamsSchema }, + response: { description: "Marketplace detail.", schema: marketplaceDetailResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + createMarketplace: { + audience: "admin", + description: "Create a private-by-default marketplace.", + method: "POST", + path: pluginArchRoutePaths.marketplaces, + request: { body: marketplaceCreateSchema, params: orgIdParamSchema }, + response: { description: "Marketplace created successfully.", schema: marketplaceMutationResponseSchema, status: 201 }, + tag: "Marketplaces", + }, + updateMarketplace: { + audience: "admin", + description: "Patch marketplace metadata.", + method: "PATCH", + path: pluginArchRoutePaths.marketplace, + request: { body: marketplaceUpdateSchema, params: marketplaceParamsSchema }, + response: { description: "Marketplace updated successfully.", schema: marketplaceMutationResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + archiveMarketplace: { + audience: "admin", + description: "Archive a marketplace without deleting membership history.", + method: "POST", + path: pluginArchRoutePaths.marketplaceArchive, + request: { params: marketplaceParamsSchema }, + response: { description: "Archived marketplace detail.", schema: marketplaceMutationResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + restoreMarketplace: { + audience: "admin", + description: "Restore an archived or deleted marketplace.", + method: "POST", + path: pluginArchRoutePaths.marketplaceRestore, + request: { params: marketplaceParamsSchema }, + response: { description: "Restored marketplace detail.", schema: marketplaceMutationResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + listMarketplacePlugins: { + audience: "admin", + description: "List marketplace memberships and the plugins they reference.", + method: "GET", + path: pluginArchRoutePaths.marketplacePlugins, + request: { params: marketplaceParamsSchema }, + response: { description: "Marketplace plugin memberships.", schema: marketplacePluginListResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + addMarketplacePlugin: { + audience: "admin", + description: "Add a plugin to a marketplace using marketplace-scoped write access.", + method: "POST", + path: pluginArchRoutePaths.marketplacePlugins, + request: { body: marketplacePluginWriteSchema, params: marketplaceParamsSchema }, + response: { description: "Marketplace plugin membership created successfully.", schema: marketplacePluginMutationResponseSchema, status: 201 }, + tag: "Marketplaces", + }, + removeMarketplacePlugin: { + audience: "admin", + description: "Remove one plugin membership from a marketplace.", + method: "DELETE", + path: pluginArchRoutePaths.marketplacePlugin, + request: { params: marketplacePluginParamsSchema }, + response: { description: "Marketplace plugin membership removed successfully.", status: 204 }, + tag: "Marketplaces", + }, + listMarketplaceAccess: { + audience: "admin", + description: "List direct, team, and org-wide grants for a marketplace.", + method: "GET", + path: pluginArchRoutePaths.marketplaceAccess, + request: { params: marketplaceParamsSchema }, + response: { description: "Marketplace access grants.", schema: accessGrantListResponseSchema, status: 200 }, + tag: "Marketplaces", + }, + grantMarketplaceAccess: { + audience: "admin", + description: "Create one direct, team, or org-wide access grant for a marketplace.", + method: "POST", + path: pluginArchRoutePaths.marketplaceAccess, + request: { body: resourceAccessGrantWriteSchema, params: marketplaceParamsSchema }, + response: { description: "Marketplace access grant created successfully.", schema: accessGrantMutationResponseSchema, status: 201 }, + tag: "Marketplaces", + }, + revokeMarketplaceAccess: { + audience: "admin", + description: "Soft-revoke one marketplace access grant.", + method: "DELETE", + path: pluginArchRoutePaths.marketplaceAccessGrant, + request: { params: marketplaceAccessGrantParamsSchema }, + response: { description: "Marketplace access grant revoked successfully.", status: 204 }, + tag: "Marketplaces", + }, listConnectorAccounts: { audience: "admin", description: "List connector accounts such as GitHub App installations available to the org.", diff --git a/ee/apps/den-api/src/routes/org/plugin-system/routes.ts b/ee/apps/den-api/src/routes/org/plugin-system/routes.ts index 281d9d3c..42bc4422 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/routes.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/routes.ts @@ -60,6 +60,18 @@ import { githubConnectorSetupSchema, githubValidateTargetResponseSchema, githubValidateTargetSchema, + marketplaceAccessGrantParamsSchema, + marketplaceCreateSchema, + marketplaceDetailResponseSchema, + marketplaceListQuerySchema, + marketplaceListResponseSchema, + marketplaceMutationResponseSchema, + marketplaceParamsSchema, + marketplacePluginListResponseSchema, + marketplacePluginMutationResponseSchema, + marketplacePluginParamsSchema, + marketplacePluginWriteSchema, + marketplaceUpdateSchema, pluginAccessGrantParamsSchema, pluginCreateSchema, pluginDetailResponseSchema, @@ -85,6 +97,7 @@ import { createConnectorInstance, createConnectorMapping, createGithubConnectorAccount, + createMarketplace, createPlugin, createResourceAccessGrant, createConnectorTarget, @@ -98,6 +111,7 @@ import { getConnectorSyncEventDetail, getConnectorTargetDetail, getLatestConfigObjectVersion, + getMarketplaceDetail, getPluginDetail, githubSetup, listConfigObjectPlugins, @@ -109,19 +123,25 @@ import { listConnectorSyncEvents, listConnectorTargets, listGithubRepositories, + listMarketplaceMemberships, + listMarketplaces, listPluginMemberships, listPlugins, listResourceAccess, + attachPluginToMarketplace, queueConnectorTargetResync, removeConfigObjectFromPlugin, + removePluginFromMarketplace, removePluginMembership, retryConnectorSyncEvent, setConfigObjectLifecycle, setConnectorInstanceLifecycle, + setMarketplaceLifecycle, setPluginLifecycle, updateConnectorInstance, updateConnectorMapping, updateConnectorTarget, + updateMarketplace, updatePlugin, validateGithubTarget, } from "./store.js" @@ -807,6 +827,263 @@ export function registerPluginArchRoutes { + const query = validQuery(c) + return c.json(await listMarketplaces({ context: actorContext(c), cursor: query.cursor, limit: query.limit, q: query.q, status: query.status })) + }) + + withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplaces, + paramValidator(marketplaceParamsSchema.pick({ orgId: true })), + jsonValidator(marketplaceCreateSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Create marketplace", + description: "Creates a new private marketplace and grants the creator manager access.", + responses: { + 201: jsonResponse("Marketplace created successfully.", marketplaceMutationResponseSchema), + 400: jsonResponse("The marketplace creation request was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to create marketplaces.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to create marketplaces.", forbiddenSchema), + }, + }), + async (c: OrgContext) => { + try { + const context = actorContext(c) + await requirePluginArchCapability(context, "marketplace.create") + const body = validJson(c) + return c.json({ ok: true, item: await createMarketplace({ context, description: body.description, name: body.name }) }, 201) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplace, + paramValidator(marketplaceParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Get marketplace", + description: "Returns one marketplace detail when the caller can view it.", + responses: { + 200: jsonResponse("Marketplace returned successfully.", marketplaceDetailResponseSchema), + 400: jsonResponse("The marketplace path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to view marketplaces.", unauthorizedSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + return c.json({ item: await getMarketplaceDetail(actorContext(c), params.marketplaceId) }) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "patch", pluginArchRoutePaths.marketplace, + paramValidator(marketplaceParamsSchema), + jsonValidator(marketplaceUpdateSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Update marketplace", + description: "Updates marketplace metadata.", + responses: { + 200: jsonResponse("Marketplace updated successfully.", marketplaceMutationResponseSchema), + 400: jsonResponse("The marketplace update request was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to update marketplaces.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + const body = validJson(c) + return c.json({ ok: true, item: await updateMarketplace({ context: actorContext(c), description: body.description, marketplaceId: params.marketplaceId, name: body.name }) }) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + for (const [path, action] of [[pluginArchRoutePaths.marketplaceArchive, "archive"], [pluginArchRoutePaths.marketplaceRestore, "restore"]] as const) { + withPluginArchOrgContext(app, "post", path, + paramValidator(marketplaceParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: `${action} marketplace`, + description: `${action} a marketplace without touching membership history.`, + responses: { + 200: jsonResponse("Marketplace lifecycle updated successfully.", marketplaceMutationResponseSchema), + 400: jsonResponse("The marketplace lifecycle path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplaces.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to manage this marketplace.", forbiddenSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + return c.json({ ok: true, item: await setMarketplaceLifecycle({ action, context: actorContext(c), marketplaceId: params.marketplaceId }) }) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + } + + withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplacePlugins, + paramValidator(marketplaceParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "List marketplace plugins", + description: "Lists marketplace memberships and resolved plugin projections.", + responses: { + 200: jsonResponse("Marketplace memberships returned successfully.", marketplacePluginListResponseSchema), + 400: jsonResponse("The marketplace membership path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to view marketplace memberships.", unauthorizedSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + return c.json(await listMarketplaceMemberships({ context: actorContext(c), includePlugins: true, marketplaceId: params.marketplaceId, onlyActive: false })) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplacePlugins, + paramValidator(marketplaceParamsSchema), + jsonValidator(marketplacePluginWriteSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Add marketplace plugin", + description: "Adds a plugin to a marketplace.", + responses: { + 201: jsonResponse("Marketplace membership created successfully.", marketplacePluginMutationResponseSchema), + 400: jsonResponse("The marketplace membership request was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplace memberships.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema), + 404: jsonResponse("The marketplace or plugin could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + const body = validJson(c) + return c.json({ ok: true, item: await attachPluginToMarketplace({ context: actorContext(c), marketplaceId: params.marketplaceId, membershipSource: body.membershipSource, pluginId: body.pluginId }) }, 201) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "delete", pluginArchRoutePaths.marketplacePlugin, + paramValidator(marketplacePluginParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Remove marketplace plugin", + description: "Removes one plugin from a marketplace.", + responses: { + 204: emptyResponse("Marketplace membership removed successfully."), + 400: jsonResponse("The marketplace membership path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplace memberships.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema), + 404: jsonResponse("The marketplace membership could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + await removePluginFromMarketplace({ context: actorContext(c), marketplaceId: params.marketplaceId, pluginId: params.pluginId }) + return c.body(null, 204) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplaceAccess, + paramValidator(marketplaceParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "List marketplace access grants", + description: "Lists direct, team, and org-wide grants for a marketplace.", + responses: { + 200: jsonResponse("Marketplace access grants returned successfully.", accessGrantListResponseSchema), + 400: jsonResponse("The marketplace access path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + return c.json(await listResourceAccess({ context: actorContext(c), resourceId: params.marketplaceId, resourceKind: "marketplace" })) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplaceAccess, + paramValidator(marketplaceParamsSchema), + jsonValidator(resourceAccessGrantWriteSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Grant marketplace access", + description: "Creates or reactivates one access grant for a marketplace.", + responses: { + 201: jsonResponse("Marketplace access grant created successfully.", accessGrantMutationResponseSchema), + 400: jsonResponse("The marketplace access request was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema), + 404: jsonResponse("The marketplace could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + return c.json({ ok: true, item: await createResourceAccessGrant({ context: actorContext(c), resourceId: params.marketplaceId, resourceKind: "marketplace", value: validJson(c) }) }, 201) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + + withPluginArchOrgContext(app, "delete", pluginArchRoutePaths.marketplaceAccessGrant, + paramValidator(marketplaceAccessGrantParamsSchema), + describeRoute({ + tags: ["Marketplaces"], + summary: "Revoke marketplace access", + description: "Soft-revokes one marketplace access grant.", + responses: { + 204: emptyResponse("Marketplace access revoked successfully."), + 400: jsonResponse("The marketplace access path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema), + 403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema), + 404: jsonResponse("The access grant could not be found.", notFoundSchema), + }, + }), + async (c: OrgContext) => { + try { + const params = validParam(c) + await deleteResourceAccessGrant({ context: actorContext(c), grantId: params.grantId, resourceId: params.marketplaceId, resourceKind: "marketplace" }) + return c.body(null, 204) + } catch (error) { + return routeErrorResponse(c, error) + } + }) + withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorAccounts, paramValidator(connectorAccountParamsSchema.pick({ orgId: true })), queryValidator(connectorAccountListQuerySchema), diff --git a/ee/apps/den-api/src/routes/org/plugin-system/schemas.ts b/ee/apps/den-api/src/routes/org/plugin-system/schemas.ts index 2ff297ba..a47d666f 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/schemas.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/schemas.ts @@ -11,6 +11,7 @@ import { connectorSyncStatusValues, connectorTargetKindValues, connectorTypeValues, + marketplaceStatusValues, membershipSourceValues, pluginStatusValues, } from "@openwork-ee/den-db/schema" @@ -33,6 +34,9 @@ export const configObjectAccessGrantIdSchema = denTypeIdSchema("configObjectAcce export const pluginIdSchema = denTypeIdSchema("plugin") export const pluginConfigObjectIdSchema = denTypeIdSchema("pluginConfigObject") export const pluginAccessGrantIdSchema = denTypeIdSchema("pluginAccessGrant") +export const marketplaceIdSchema = denTypeIdSchema("marketplace") +export const marketplacePluginIdSchema = denTypeIdSchema("marketplacePlugin") +export const marketplaceAccessGrantIdSchema = denTypeIdSchema("marketplaceAccessGrant") export const connectorAccountIdSchema = denTypeIdSchema("connectorAccount") export const connectorInstanceIdSchema = denTypeIdSchema("connectorInstance") export const connectorInstanceAccessGrantIdSchema = denTypeIdSchema("connectorInstanceAccessGrant") @@ -49,6 +53,7 @@ export const configObjectSourceModeSchema = z.enum(configObjectSourceModeValues) export const configObjectCreatedViaSchema = z.enum(configObjectCreatedViaValues) export const configObjectStatusSchema = z.enum(configObjectStatusValues) export const pluginStatusSchema = z.enum(pluginStatusValues) +export const marketplaceStatusSchema = z.enum(marketplaceStatusValues) export const membershipSourceSchema = z.enum(membershipSourceValues) export const accessRoleSchema = z.enum(accessRoleValues) export const connectorTypeSchema = z.enum(connectorTypeValues) @@ -84,6 +89,11 @@ export const pluginListQuerySchema = pluginArchPaginationQuerySchema.extend({ q: z.string().trim().min(1).max(255).optional(), }) +export const marketplaceListQuerySchema = pluginArchPaginationQuerySchema.extend({ + status: marketplaceStatusSchema.optional(), + q: z.string().trim().min(1).max(255).optional(), +}) + export const connectorAccountListQuerySchema = pluginArchPaginationQuerySchema.extend({ connectorType: connectorTypeSchema.optional(), status: connectorAccountStatusSchema.optional(), @@ -128,6 +138,9 @@ export const configObjectAccessGrantParamsSchema = configObjectParamsSchema.exte export const pluginParamsSchema = orgIdParamSchema.extend(idParamSchema("pluginId", "plugin").shape) export const pluginConfigObjectParamsSchema = pluginParamsSchema.extend(idParamSchema("configObjectId", "configObject").shape) export const pluginAccessGrantParamsSchema = pluginParamsSchema.extend(idParamSchema("grantId", "pluginAccessGrant").shape) +export const marketplaceParamsSchema = orgIdParamSchema.extend(idParamSchema("marketplaceId", "marketplace").shape) +export const marketplacePluginParamsSchema = marketplaceParamsSchema.extend(idParamSchema("pluginId", "plugin").shape) +export const marketplaceAccessGrantParamsSchema = marketplaceParamsSchema.extend(idParamSchema("grantId", "marketplaceAccessGrant").shape) export const connectorAccountParamsSchema = orgIdParamSchema.extend(idParamSchema("connectorAccountId", "connectorAccount").shape) export const connectorInstanceParamsSchema = orgIdParamSchema.extend(idParamSchema("connectorInstanceId", "connectorInstance").shape) export const connectorInstanceAccessGrantParamsSchema = connectorInstanceParamsSchema.extend(idParamSchema("grantId", "connectorInstanceAccessGrant").shape) @@ -204,11 +217,34 @@ export const pluginUpdateSchema = z.object({ } }) +export const marketplaceCreateSchema = z.object({ + name: z.string().trim().min(1).max(255), + description: nullableStringSchema.optional(), +}) + +export const marketplaceUpdateSchema = z.object({ + name: z.string().trim().min(1).max(255).optional(), + description: nullableStringSchema.optional(), +}).superRefine((value, ctx) => { + if (value.name === undefined && value.description === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide at least one field to update.", + path: ["name"], + }) + } +}) + export const pluginMembershipWriteSchema = z.object({ configObjectId: configObjectIdSchema, membershipSource: membershipSourceSchema.optional(), }) +export const marketplacePluginWriteSchema = z.object({ + pluginId: pluginIdSchema, + membershipSource: membershipSourceSchema.optional(), +}) + export const connectorAccountCreateSchema = z.object({ connectorType: connectorTypeSchema, remoteId: z.string().trim().min(1).max(255), @@ -324,7 +360,7 @@ export const githubValidateTargetSchema = z.object({ }) export const accessGrantSchema = z.object({ - id: z.union([configObjectAccessGrantIdSchema, pluginAccessGrantIdSchema, connectorInstanceAccessGrantIdSchema]), + id: z.union([configObjectAccessGrantIdSchema, pluginAccessGrantIdSchema, marketplaceAccessGrantIdSchema, connectorInstanceAccessGrantIdSchema]), orgMembershipId: memberIdSchema.nullable(), teamId: teamIdSchema.nullable(), orgWide: z.boolean(), @@ -393,6 +429,30 @@ export const pluginSchema = z.object({ memberCount: z.number().int().nonnegative().optional(), }).meta({ ref: "PluginArchPlugin" }) +export const marketplacePluginSchema = z.object({ + id: marketplacePluginIdSchema, + marketplaceId: marketplaceIdSchema, + pluginId: pluginIdSchema, + membershipSource: membershipSourceSchema, + createdByOrgMembershipId: memberIdSchema.nullable(), + createdAt: z.string().datetime({ offset: true }), + removedAt: nullableTimestampSchema, + plugin: pluginSchema.optional(), +}).meta({ ref: "PluginArchMarketplacePluginMembership" }) + +export const marketplaceSchema = z.object({ + id: marketplaceIdSchema, + organizationId: denTypeIdSchema("organization"), + name: z.string().trim().min(1).max(255), + description: nullableStringSchema, + status: marketplaceStatusSchema, + createdByOrgMembershipId: memberIdSchema, + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), + deletedAt: nullableTimestampSchema, + pluginCount: z.number().int().nonnegative().optional(), +}).meta({ ref: "PluginArchMarketplace" }) + export const connectorAccountSchema = z.object({ id: connectorAccountIdSchema, organizationId: denTypeIdSchema("organization"), @@ -609,6 +669,11 @@ export const pluginMutationResponseSchema = pluginArchMutationResponseSchema("Pl export const pluginMembershipListResponseSchema = pluginArchListResponseSchema("PluginArchPluginMembershipListResponse", pluginMembershipSchema) export const pluginMembershipDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchPluginMembershipDetailResponse", pluginMembershipSchema) export const pluginMembershipMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchPluginMembershipMutationResponse", pluginMembershipSchema) +export const marketplaceListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplaceListResponse", marketplaceSchema) +export const marketplaceDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchMarketplaceDetailResponse", marketplaceSchema) +export const marketplaceMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplaceMutationResponse", marketplaceSchema) +export const marketplacePluginListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplacePluginListResponse", marketplacePluginSchema) +export const marketplacePluginMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplacePluginMutationResponse", marketplacePluginSchema) export const accessGrantListResponseSchema = pluginArchListResponseSchema("PluginArchAccessGrantListResponse", accessGrantSchema) export const accessGrantMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchAccessGrantMutationResponse", accessGrantSchema) export const connectorAccountListResponseSchema = pluginArchListResponseSchema("PluginArchConnectorAccountListResponse", connectorAccountSchema) diff --git a/ee/apps/den-api/src/routes/org/plugin-system/store.ts b/ee/apps/den-api/src/routes/org/plugin-system/store.ts index cefb1c8f..6ddc1931 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/store.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/store.ts @@ -11,6 +11,9 @@ import { ConnectorSourceTombstoneTable, ConnectorSyncEventTable, ConnectorTargetTable, + MarketplaceAccessGrantTable, + MarketplacePluginTable, + MarketplaceTable, PluginAccessGrantTable, PluginConfigObjectTable, PluginTable, @@ -25,17 +28,23 @@ type MemberId = PluginArchActorContext["organizationContext"]["currentMember"][" type TeamId = PluginArchActorContext["memberTeams"][number]["id"] type ConfigObjectRow = typeof ConfigObjectTable.$inferSelect type ConfigObjectVersionRow = typeof ConfigObjectVersionTable.$inferSelect +type MarketplaceRow = typeof MarketplaceTable.$inferSelect +type MarketplaceMembershipRow = typeof MarketplacePluginTable.$inferSelect type PluginRow = typeof PluginTable.$inferSelect type PluginMembershipRow = typeof PluginConfigObjectTable.$inferSelect type ConfigObjectId = ConfigObjectRow["id"] type ConfigObjectVersionId = ConfigObjectVersionRow["id"] +type MarketplaceId = MarketplaceRow["id"] +type MarketplaceMembershipId = MarketplaceMembershipRow["id"] type PluginId = PluginRow["id"] type PluginMembershipId = PluginMembershipRow["id"] type AccessGrantRow = | typeof ConfigObjectAccessGrantTable.$inferSelect + | typeof MarketplaceAccessGrantTable.$inferSelect | typeof PluginAccessGrantTable.$inferSelect | typeof ConnectorInstanceAccessGrantTable.$inferSelect type ConfigObjectAccessGrantId = typeof ConfigObjectAccessGrantTable.$inferSelect.id +type MarketplaceAccessGrantId = typeof MarketplaceAccessGrantTable.$inferSelect.id type PluginAccessGrantId = typeof PluginAccessGrantTable.$inferSelect.id type ConnectorInstanceAccessGrantId = typeof ConnectorInstanceAccessGrantTable.$inferSelect.id type ConnectorAccountRow = typeof ConnectorAccountTable.$inferSelect @@ -86,6 +95,11 @@ type PluginResourceTarget = { resourceKind: "plugin" } +type MarketplaceResourceTarget = { + resourceId: MarketplaceId + resourceKind: "marketplace" +} + type ConnectorInstanceResourceTarget = { resourceId: ConnectorInstanceId resourceKind: "connector_instance" @@ -93,13 +107,15 @@ type ConnectorInstanceResourceTarget = { type ResourceTarget = | ConfigObjectResourceTarget + | MarketplaceResourceTarget | PluginResourceTarget | ConnectorInstanceResourceTarget type ConfigObjectGrantTarget = ConfigObjectResourceTarget & { grantId: ConfigObjectAccessGrantId } +type MarketplaceGrantTarget = MarketplaceResourceTarget & { grantId: MarketplaceAccessGrantId } type PluginGrantTarget = PluginResourceTarget & { grantId: PluginAccessGrantId } type ConnectorInstanceGrantTarget = ConnectorInstanceResourceTarget & { grantId: ConnectorInstanceAccessGrantId } -type GrantTarget = ConfigObjectGrantTarget | PluginGrantTarget | ConnectorInstanceGrantTarget +type GrantTarget = ConfigObjectGrantTarget | MarketplaceGrantTarget | PluginGrantTarget | ConnectorInstanceGrantTarget export class PluginArchRouteFailure extends Error { constructor( @@ -254,6 +270,21 @@ function serializePlugin(row: PluginRow, memberCount?: number) { } } +function serializeMarketplace(row: MarketplaceRow, pluginCount?: number) { + return { + createdAt: row.createdAt.toISOString(), + createdByOrgMembershipId: row.createdByOrgMembershipId, + deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null, + description: row.description, + id: row.id, + name: row.name, + organizationId: row.organizationId, + pluginCount, + status: row.status, + updatedAt: row.updatedAt.toISOString(), + } +} + function serializeMembership(row: PluginMembershipRow, configObject?: ReturnType) { return { configObject, @@ -268,6 +299,19 @@ function serializeMembership(row: PluginMembershipRow, configObject?: ReturnType } } +function serializeMarketplaceMembership(row: MarketplaceMembershipRow, plugin?: ReturnType) { + return { + createdAt: row.createdAt.toISOString(), + createdByOrgMembershipId: row.createdByOrgMembershipId, + id: row.id, + marketplaceId: row.marketplaceId, + membershipSource: row.membershipSource, + plugin, + pluginId: row.pluginId, + removedAt: row.removedAt ? row.removedAt.toISOString() : null, + } +} + function serializeAccessGrant(row: AccessGrantRow) { return { createdAt: row.createdAt.toISOString(), @@ -385,6 +429,16 @@ async function getPluginRow(organizationId: OrganizationId, pluginId: PluginId) return rows[0] ?? null } +async function getMarketplaceRow(organizationId: OrganizationId, marketplaceId: MarketplaceId) { + const rows = await db + .select() + .from(MarketplaceTable) + .where(and(eq(MarketplaceTable.organizationId, organizationId), eq(MarketplaceTable.id, marketplaceId))) + .limit(1) + + return rows[0] ?? null +} + async function getConnectorAccountRow(organizationId: OrganizationId, connectorAccountId: ConnectorAccountId) { const rows = await db .select() @@ -456,6 +510,24 @@ async function ensureEditablePlugin(context: PluginArchActorContext, pluginId: P return row } +async function ensureEditableMarketplace(context: PluginArchActorContext, marketplaceId: MarketplaceId) { + const row = await getMarketplaceRow(context.organizationContext.organization.id, marketplaceId) + if (!row) { + throw new PluginArchRouteFailure(404, "marketplace_not_found", "Marketplace not found.") + } + await requirePluginArchResourceRole({ context, resourceId: row.id, resourceKind: "marketplace", role: "editor" }) + return row +} + +async function ensureVisibleMarketplace(context: PluginArchActorContext, marketplaceId: MarketplaceId) { + const row = await getMarketplaceRow(context.organizationContext.organization.id, marketplaceId) + if (!row) { + throw new PluginArchRouteFailure(404, "marketplace_not_found", "Marketplace not found.") + } + await requirePluginArchResourceRole({ context, resourceId: row.id, resourceKind: "marketplace", role: "viewer" }) + return row +} + async function ensureVisiblePlugin(context: PluginArchActorContext, pluginId: PluginId) { const row = await getPluginRow(context.organizationContext.organization.id, pluginId) if (!row) { @@ -535,6 +607,50 @@ async function upsertGrant(input: ResourceTarget & { return serializeAccessGrant({ ...row, removedAt: null }) } + if (input.resourceKind === "marketplace") { + const existing = await db + .select() + .from(MarketplaceAccessGrantTable) + .where(and( + eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId), + input.value.orgMembershipId + ? eq(MarketplaceAccessGrantTable.orgMembershipId, input.value.orgMembershipId) + : input.value.teamId + ? eq(MarketplaceAccessGrantTable.teamId, input.value.teamId) + : eq(MarketplaceAccessGrantTable.orgWide, true), + )) + .limit(1) + + if (existing[0]) { + await db + .update(MarketplaceAccessGrantTable) + .set({ + createdByOrgMembershipId, + orgMembershipId: input.value.orgMembershipId ?? null, + orgWide: input.value.orgWide ?? false, + removedAt: null, + role: input.value.role, + teamId: input.value.teamId ?? null, + }) + .where(eq(MarketplaceAccessGrantTable.id, existing[0].id)) + return serializeAccessGrant({ ...existing[0], createdByOrgMembershipId, orgMembershipId: input.value.orgMembershipId ?? null, orgWide: input.value.orgWide ?? false, removedAt: null, role: input.value.role, teamId: input.value.teamId ?? null }) + } + + const row = { + createdAt, + createdByOrgMembershipId, + id: createDenTypeId("marketplaceAccessGrant"), + marketplaceId: input.resourceId, + organizationId, + orgMembershipId: input.value.orgMembershipId ?? null, + orgWide: input.value.orgWide ?? false, + role: input.value.role, + teamId: input.value.teamId ?? null, + } + await db.insert(MarketplaceAccessGrantTable).values(row) + return serializeAccessGrant({ ...row, removedAt: null }) + } + if (input.resourceKind === "plugin") { const existing = await db .select() @@ -634,6 +750,16 @@ async function removeGrant(input: GrantTarget & { context: PluginArchActorContex await db.update(ConfigObjectAccessGrantTable).set({ removedAt }).where(eq(ConfigObjectAccessGrantTable.id, input.grantId)) return } + if (input.resourceKind === "marketplace") { + const rows = await db + .select() + .from(MarketplaceAccessGrantTable) + .where(and(eq(MarketplaceAccessGrantTable.id, input.grantId), eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId))) + .limit(1) + if (!rows[0]) throw new PluginArchRouteFailure(404, "access_grant_not_found", "Access grant not found.") + await db.update(MarketplaceAccessGrantTable).set({ removedAt }).where(eq(MarketplaceAccessGrantTable.id, input.grantId)) + return + } if (input.resourceKind === "plugin") { const rows = await db .select() @@ -970,6 +1096,10 @@ export async function listResourceAccess(input: { context: PluginArchActorContex const rows = await db.select().from(ConfigObjectAccessGrantTable).where(eq(ConfigObjectAccessGrantTable.configObjectId, input.resourceId)).orderBy(desc(ConfigObjectAccessGrantTable.createdAt)) return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null } } + if (input.resourceKind === "marketplace") { + const rows = await db.select().from(MarketplaceAccessGrantTable).where(eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId)).orderBy(desc(MarketplaceAccessGrantTable.createdAt)) + return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null } + } if (input.resourceKind === "plugin") { const rows = await db.select().from(PluginAccessGrantTable).where(eq(PluginAccessGrantTable.pluginId, input.resourceId)).orderBy(desc(PluginAccessGrantTable.createdAt)) return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null } @@ -1107,6 +1237,166 @@ export async function removePluginMembership(input: { configObjectId: ConfigObje return removeConfigObjectFromPlugin(input) } +export async function listMarketplaces(input: { context: PluginArchActorContext; cursor?: string; limit?: number; q?: string; status?: MarketplaceRow["status"] }) { + const rows = await db + .select() + .from(MarketplaceTable) + .where(eq(MarketplaceTable.organizationId, input.context.organizationContext.organization.id)) + .orderBy(desc(MarketplaceTable.updatedAt), desc(MarketplaceTable.id)) + + const memberships = await db + .select({ marketplaceId: MarketplacePluginTable.marketplaceId, count: MarketplacePluginTable.id }) + .from(MarketplacePluginTable) + .where(isNull(MarketplacePluginTable.removedAt)) + + const counts = memberships.reduce((accumulator, row) => { + accumulator.set(row.marketplaceId, (accumulator.get(row.marketplaceId) ?? 0) + 1) + return accumulator + }, new Map()) + + const visible: ReturnType[] = [] + for (const row of rows) { + const role = await resolvePluginArchResourceRole({ context: input.context, resourceId: row.id, resourceKind: "marketplace" }) + if (!role) continue + if (input.status && row.status !== input.status) continue + if (input.q) { + const haystack = `${row.name}\n${row.description ?? ""}`.toLowerCase() + if (!haystack.includes(input.q.toLowerCase())) continue + } + visible.push(serializeMarketplace(row, counts.get(row.id) ?? 0)) + } + + return pageItems(visible, input.cursor, input.limit) +} + +export async function getMarketplaceDetail(context: PluginArchActorContext, marketplaceId: MarketplaceId) { + const row = await ensureVisibleMarketplace(context, marketplaceId) + const memberships = await db + .select({ id: MarketplacePluginTable.id }) + .from(MarketplacePluginTable) + .where(and(eq(MarketplacePluginTable.marketplaceId, row.id), isNull(MarketplacePluginTable.removedAt))) + return serializeMarketplace(row, memberships.length) +} + +export async function createMarketplace(input: { context: PluginArchActorContext; description?: string | null; name: string }) { + const now = new Date() + const row = { + createdAt: now, + createdByOrgMembershipId: input.context.organizationContext.currentMember.id, + deletedAt: null, + description: normalizeOptionalString(input.description ?? undefined), + id: createDenTypeId("marketplace"), + name: input.name.trim(), + organizationId: input.context.organizationContext.organization.id, + status: "active" as const, + updatedAt: now, + } + + await db.transaction(async (tx) => { + await tx.insert(MarketplaceTable).values(row) + await tx.insert(MarketplaceAccessGrantTable).values({ + createdAt: now, + createdByOrgMembershipId: input.context.organizationContext.currentMember.id, + id: createDenTypeId("marketplaceAccessGrant"), + marketplaceId: row.id, + organizationId: input.context.organizationContext.organization.id, + orgMembershipId: input.context.organizationContext.currentMember.id, + orgWide: false, + role: "manager", + teamId: null, + }) + }) + + return serializeMarketplace(row, 0) +} + +export async function updateMarketplace(input: { context: PluginArchActorContext; description?: string | null; marketplaceId: MarketplaceId; name?: string }) { + const row = await ensureEditableMarketplace(input.context, input.marketplaceId) + const updatedAt = new Date() + await db.update(MarketplaceTable).set({ + description: input.description === undefined ? row.description : normalizeOptionalString(input.description ?? undefined), + name: input.name?.trim() || row.name, + updatedAt, + }).where(eq(MarketplaceTable.id, row.id)) + return getMarketplaceDetail(input.context, row.id) +} + +export async function setMarketplaceLifecycle(input: { action: "archive" | "restore"; context: PluginArchActorContext; marketplaceId: MarketplaceId }) { + const row = await ensureVisibleMarketplace(input.context, input.marketplaceId) + await requirePluginArchResourceRole({ context: input.context, resourceId: row.id, resourceKind: "marketplace", role: "manager" }) + const updatedAt = new Date() + await db.update(MarketplaceTable).set({ + deletedAt: input.action === "archive" ? row.deletedAt : null, + status: input.action === "archive" ? "archived" : "active", + updatedAt, + }).where(eq(MarketplaceTable.id, row.id)) + return getMarketplaceDetail(input.context, row.id) +} + +export async function listMarketplaceMemberships(input: { context: PluginArchActorContext; includePlugins?: boolean; marketplaceId: MarketplaceId; onlyActive?: boolean }) { + await ensureVisibleMarketplace(input.context, input.marketplaceId) + const memberships = await db + .select() + .from(MarketplacePluginTable) + .where(input.onlyActive ? and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), isNull(MarketplacePluginTable.removedAt)) : eq(MarketplacePluginTable.marketplaceId, input.marketplaceId)) + .orderBy(desc(MarketplacePluginTable.createdAt)) + + if (!input.includePlugins) { + return { items: memberships.map((membership) => serializeMarketplaceMembership(membership)), nextCursor: null } + } + + const plugins = memberships.length === 0 + ? [] + : await db.select().from(PluginTable).where(inArray(PluginTable.id, memberships.map((membership) => membership.pluginId))) + const byId = new Map>(plugins.map((row) => [row.id, serializePlugin(row)])) + return { items: memberships.map((membership) => serializeMarketplaceMembership(membership, byId.get(membership.pluginId))), nextCursor: null } +} + +export async function attachPluginToMarketplace(input: { context: PluginArchActorContext; marketplaceId: MarketplaceId; membershipSource?: MarketplaceMembershipRow["membershipSource"]; pluginId: PluginId }) { + await ensureVisiblePlugin(input.context, input.pluginId) + await ensureEditableMarketplace(input.context, input.marketplaceId) + + const existing = await db + .select() + .from(MarketplacePluginTable) + .where(and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), eq(MarketplacePluginTable.pluginId, input.pluginId))) + .limit(1) + + const now = new Date() + let membershipId: MarketplaceMembershipId | null = existing[0]?.id ?? null + if (existing[0]) { + await db.update(MarketplacePluginTable).set({ membershipSource: input.membershipSource ?? existing[0].membershipSource, removedAt: null }).where(eq(MarketplacePluginTable.id, existing[0].id)) + } else { + membershipId = createDenTypeId("marketplacePlugin") + await db.insert(MarketplacePluginTable).values({ + createdAt: now, + createdByOrgMembershipId: input.context.organizationContext.currentMember.id, + id: membershipId, + marketplaceId: input.marketplaceId, + membershipSource: input.membershipSource ?? "manual", + organizationId: input.context.organizationContext.organization.id, + pluginId: input.pluginId, + }) + } + + const rows = await db.select().from(MarketplacePluginTable).where(eq(MarketplacePluginTable.id, membershipId!)).limit(1) + return serializeMarketplaceMembership(rows[0]) +} + +export async function removePluginFromMarketplace(input: { context: PluginArchActorContext; marketplaceId: MarketplaceId; pluginId: PluginId }) { + await ensureVisiblePlugin(input.context, input.pluginId) + await ensureEditableMarketplace(input.context, input.marketplaceId) + const rows = await db + .select() + .from(MarketplacePluginTable) + .where(and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), eq(MarketplacePluginTable.pluginId, input.pluginId), isNull(MarketplacePluginTable.removedAt))) + .limit(1) + if (!rows[0]) { + throw new PluginArchRouteFailure(404, "marketplace_membership_not_found", "Marketplace membership not found.") + } + await db.update(MarketplacePluginTable).set({ removedAt: new Date() }).where(eq(MarketplacePluginTable.id, rows[0].id)) +} + export async function listConnectorAccounts(input: { context: PluginArchActorContext; connectorType?: ConnectorAccountRow["connectorType"]; cursor?: string; limit?: number; q?: string; status?: ConnectorAccountRow["status"] }) { const rows = await db .select() diff --git a/ee/apps/den-api/test/plugin-system-access.test.ts b/ee/apps/den-api/test/plugin-system-access.test.ts index 6575d5b8..ad68e2f5 100644 --- a/ee/apps/den-api/test/plugin-system-access.test.ts +++ b/ee/apps/den-api/test/plugin-system-access.test.ts @@ -41,6 +41,7 @@ test("org owners and admins get plugin-system capability access", () => { expect(accessModule.isPluginArchOrgAdmin(createActorContext({ role: "member" }))).toBe(false) expect(accessModule.hasPluginArchCapability(createActorContext({ isOwner: true }), "plugin.create")).toBe(true) + expect(accessModule.hasPluginArchCapability(createActorContext({ role: "admin" }), "marketplace.create")).toBe(true) expect(accessModule.hasPluginArchCapability(createActorContext({ role: "admin" }), "connector_instance.create")).toBe(true) expect(accessModule.hasPluginArchCapability(createActorContext({ role: "member" }), "config_object.create")).toBe(false) }) diff --git a/ee/packages/den-db/drizzle/0011_marketplaces.sql b/ee/packages/den-db/drizzle/0011_marketplaces.sql new file mode 100644 index 00000000..38fbc255 --- /dev/null +++ b/ee/packages/den-db/drizzle/0011_marketplaces.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS `marketplace` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `status` enum('active','inactive','deleted','archived') NOT NULL DEFAULT 'active', + `created_by_org_membership_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `deleted_at` timestamp(3) NULL, + CONSTRAINT `marketplace_id` PRIMARY KEY(`id`), + KEY `marketplace_organization_id` (`organization_id`), + KEY `marketplace_created_by_org_membership_id` (`created_by_org_membership_id`), + KEY `marketplace_status` (`status`), + KEY `marketplace_name` (`name`) +); + +CREATE TABLE IF NOT EXISTS `marketplace_plugin` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `marketplace_id` varchar(64) NOT NULL, + `plugin_id` varchar(64) NOT NULL, + `membership_source` enum('manual','connector','api','system') NOT NULL DEFAULT 'manual', + `created_by_org_membership_id` varchar(64), + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `removed_at` timestamp(3) NULL, + CONSTRAINT `marketplace_plugin_id` PRIMARY KEY(`id`), + CONSTRAINT `marketplace_plugin_marketplace_plugin` UNIQUE(`marketplace_id`, `plugin_id`), + KEY `marketplace_plugin_organization_id` (`organization_id`), + KEY `marketplace_plugin_marketplace_id` (`marketplace_id`), + KEY `marketplace_plugin_plugin_id` (`plugin_id`) +); + +CREATE TABLE IF NOT EXISTS `marketplace_access_grant` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `marketplace_id` varchar(64) NOT NULL, + `org_membership_id` varchar(64), + `team_id` varchar(64), + `org_wide` boolean NOT NULL DEFAULT false, + `role` enum('viewer','editor','manager') NOT NULL, + `created_by_org_membership_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `removed_at` timestamp(3) NULL, + CONSTRAINT `marketplace_access_grant_id` PRIMARY KEY(`id`), + CONSTRAINT `marketplace_access_grant_marketplace_org_membership` UNIQUE(`marketplace_id`, `org_membership_id`), + CONSTRAINT `marketplace_access_grant_marketplace_team` UNIQUE(`marketplace_id`, `team_id`), + KEY `marketplace_access_grant_organization_id` (`organization_id`), + KEY `marketplace_access_grant_marketplace_id` (`marketplace_id`), + KEY `marketplace_access_grant_org_membership_id` (`org_membership_id`), + KEY `marketplace_access_grant_team_id` (`team_id`), + KEY `marketplace_access_grant_org_wide` (`org_wide`) +); diff --git a/ee/packages/den-db/drizzle/meta/_journal.json b/ee/packages/den-db/drizzle/meta/_journal.json index 9a0c1d8b..55ef96a5 100644 --- a/ee/packages/den-db/drizzle/meta/_journal.json +++ b/ee/packages/den-db/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1776427000000, "tag": "0010_plugin_arch", "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1776440000000, + "tag": "0011_marketplaces", + "breakpoints": true } ] } diff --git a/ee/packages/den-db/src/schema/sharables/plugin-arch.ts b/ee/packages/den-db/src/schema/sharables/plugin-arch.ts index cb850350..183ae82a 100644 --- a/ee/packages/den-db/src/schema/sharables/plugin-arch.ts +++ b/ee/packages/den-db/src/schema/sharables/plugin-arch.ts @@ -19,6 +19,7 @@ export const configObjectSourceModeValues = ["cloud", "import", "connector"] as export const configObjectStatusValues = ["active", "inactive", "deleted", "archived", "ingestion_error"] as const export const configObjectCreatedViaValues = ["cloud", "import", "connector", "system"] as const export const pluginStatusValues = ["active", "inactive", "deleted", "archived"] as const +export const marketplaceStatusValues = ["active", "inactive", "deleted", "archived"] as const export const membershipSourceValues = ["manual", "connector", "api", "system"] as const export const accessRoleValues = ["viewer", "editor", "manager"] as const export const connectorTypeValues = ["github"] as const @@ -114,6 +115,72 @@ export const PluginTable = mysqlTable( ], ) +export const MarketplaceTable = mysqlTable( + "marketplace", + { + id: denTypeIdColumn("marketplace", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + status: mysqlEnum("status", marketplaceStatusValues).notNull().default("active"), + createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }).notNull().default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + deletedAt: timestamp("deleted_at", { fsp: 3 }), + }, + (table) => [ + index("marketplace_organization_id").on(table.organizationId), + index("marketplace_created_by_org_membership_id").on(table.createdByOrgMembershipId), + index("marketplace_status").on(table.status), + index("marketplace_name").on(table.name), + ], +) + +export const MarketplacePluginTable = mysqlTable( + "marketplace_plugin", + { + id: denTypeIdColumn("marketplacePlugin", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + marketplaceId: denTypeIdColumn("marketplace", "marketplace_id").notNull(), + pluginId: denTypeIdColumn("plugin", "plugin_id").notNull(), + membershipSource: mysqlEnum("membership_source", membershipSourceValues).notNull().default("manual"), + createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id"), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + removedAt: timestamp("removed_at", { fsp: 3 }), + }, + (table) => [ + index("marketplace_plugin_organization_id").on(table.organizationId), + index("marketplace_plugin_marketplace_id").on(table.marketplaceId), + index("marketplace_plugin_plugin_id").on(table.pluginId), + uniqueIndex("marketplace_plugin_marketplace_plugin").on(table.marketplaceId, table.pluginId), + ], +) + +export const MarketplaceAccessGrantTable = mysqlTable( + "marketplace_access_grant", + { + id: denTypeIdColumn("marketplaceAccessGrant", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + marketplaceId: denTypeIdColumn("marketplace", "marketplace_id").notNull(), + orgMembershipId: denTypeIdColumn("member", "org_membership_id"), + teamId: denTypeIdColumn("team", "team_id"), + orgWide: boolean("org_wide").notNull().default(false), + role: mysqlEnum("role", accessRoleValues).notNull(), + createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + removedAt: timestamp("removed_at", { fsp: 3 }), + }, + (table) => [ + index("marketplace_access_grant_organization_id").on(table.organizationId), + index("marketplace_access_grant_marketplace_id").on(table.marketplaceId), + index("marketplace_access_grant_org_membership_id").on(table.orgMembershipId), + index("marketplace_access_grant_team_id").on(table.teamId), + index("marketplace_access_grant_org_wide").on(table.orgWide), + uniqueIndex("marketplace_access_grant_marketplace_org_membership").on(table.marketplaceId, table.orgMembershipId), + uniqueIndex("marketplace_access_grant_marketplace_team").on(table.marketplaceId, table.teamId), + ], +) + export const PluginConfigObjectTable = mysqlTable( "plugin_config_object", { @@ -438,6 +505,7 @@ export const pluginRelations = relations(PluginTable, ({ many, one }) => ({ fields: [PluginTable.createdByOrgMembershipId], references: [MemberTable.id], }), + marketplaces: many(MarketplacePluginTable), memberships: many(PluginConfigObjectTable), organization: one(OrganizationTable, { fields: [PluginTable.organizationId], @@ -446,6 +514,53 @@ export const pluginRelations = relations(PluginTable, ({ many, one }) => ({ mappings: many(ConnectorMappingTable), })) +export const marketplaceRelations = relations(MarketplaceTable, ({ many, one }) => ({ + accessGrants: many(MarketplaceAccessGrantTable), + createdByOrgMembership: one(MemberTable, { + fields: [MarketplaceTable.createdByOrgMembershipId], + references: [MemberTable.id], + }), + memberships: many(MarketplacePluginTable), + organization: one(OrganizationTable, { + fields: [MarketplaceTable.organizationId], + references: [OrganizationTable.id], + }), +})) + +export const marketplacePluginRelations = relations(MarketplacePluginTable, ({ one }) => ({ + createdByOrgMembership: one(MemberTable, { + fields: [MarketplacePluginTable.createdByOrgMembershipId], + references: [MemberTable.id], + }), + marketplace: one(MarketplaceTable, { + fields: [MarketplacePluginTable.marketplaceId], + references: [MarketplaceTable.id], + }), + plugin: one(PluginTable, { + fields: [MarketplacePluginTable.pluginId], + references: [PluginTable.id], + }), +})) + +export const marketplaceAccessGrantRelations = relations(MarketplaceAccessGrantTable, ({ one }) => ({ + createdByOrgMembership: one(MemberTable, { + fields: [MarketplaceAccessGrantTable.createdByOrgMembershipId], + references: [MemberTable.id], + }), + marketplace: one(MarketplaceTable, { + fields: [MarketplaceAccessGrantTable.marketplaceId], + references: [MarketplaceTable.id], + }), + orgMembership: one(MemberTable, { + fields: [MarketplaceAccessGrantTable.orgMembershipId], + references: [MemberTable.id], + }), + team: one(TeamTable, { + fields: [MarketplaceAccessGrantTable.teamId], + references: [TeamTable.id], + }), +})) + export const pluginConfigObjectRelations = relations(PluginConfigObjectTable, ({ one }) => ({ configObject: one(ConfigObjectTable, { fields: [PluginConfigObjectTable.configObjectId], @@ -643,6 +758,9 @@ export const connectorSourceTombstoneRelations = relations(ConnectorSourceTombst export const configObject = ConfigObjectTable export const configObjectVersion = ConfigObjectVersionTable export const plugin = PluginTable +export const marketplace = MarketplaceTable +export const marketplacePlugin = MarketplacePluginTable +export const marketplaceAccessGrant = MarketplaceAccessGrantTable export const pluginConfigObject = PluginConfigObjectTable export const configObjectAccessGrant = ConfigObjectAccessGrantTable export const pluginAccessGrant = PluginAccessGrantTable diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index fe2d3e42..fe8967df 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -32,6 +32,9 @@ export const idTypesMapNameToPrefix = { plugin: "plg", pluginConfigObject: "pco", pluginAccessGrant: "pag", + marketplace: "mkt", + marketplacePlugin: "mkp", + marketplaceAccessGrant: "mag", connectorAccount: "cac", connectorInstance: "cin", connectorInstanceAccessGrant: "cia", diff --git a/prds/new-plugin-arch/admin-api.md b/prds/new-plugin-arch/admin-api.md index 892b4614..5366b0f6 100644 --- a/prds/new-plugin-arch/admin-api.md +++ b/prds/new-plugin-arch/admin-api.md @@ -183,6 +183,27 @@ These should sit on top of the shared config-object model. - `POST /v1/orgs/:orgId/plugins/:pluginId/access` - `DELETE /v1/orgs/:orgId/plugins/:pluginId/access/:grantId` +## Marketplaces + +- `GET /v1/orgs/:orgId/marketplaces` +- `GET /v1/orgs/:orgId/marketplaces/:marketplaceId` +- `POST /v1/orgs/:orgId/marketplaces` +- `PATCH /v1/orgs/:orgId/marketplaces/:marketplaceId` +- `POST /v1/orgs/:orgId/marketplaces/:marketplaceId/archive` +- `POST /v1/orgs/:orgId/marketplaces/:marketplaceId/restore` + +### Marketplace/plugin membership endpoints + +- `GET /v1/orgs/:orgId/marketplaces/:marketplaceId/plugins` +- `POST /v1/orgs/:orgId/marketplaces/:marketplaceId/plugins` +- `DELETE /v1/orgs/:orgId/marketplaces/:marketplaceId/plugins/:pluginId` + +### Marketplace access endpoints + +- `GET /v1/orgs/:orgId/marketplaces/:marketplaceId/access` +- `POST /v1/orgs/:orgId/marketplaces/:marketplaceId/access` +- `DELETE /v1/orgs/:orgId/marketplaces/:marketplaceId/access/:grantId` + ## Connector accounts - `GET /v1/orgs/:orgId/connector-accounts` diff --git a/prds/new-plugin-arch/api.md b/prds/new-plugin-arch/api.md index 1cdc9db0..9cba3c7f 100644 --- a/prds/new-plugin-arch/api.md +++ b/prds/new-plugin-arch/api.md @@ -30,6 +30,7 @@ Use for: - config object CRUD - version history access - plugin management +- marketplace management - access grants - connector setup and mapping management - sync-event inspection and retries @@ -57,6 +58,7 @@ Use for: - keep one shared `config-objects` admin surface and add type-specific convenience endpoints where UI needs them; - keep current-state search/list endpoints separate from version-history endpoints; - treat plugin access management as a first-class API surface; +- treat marketplace access and marketplace-plugin composition as first-class API surfaces; - keep connector setup, target, mapping, and sync APIs explicit; - keep public webhook ingress separate from authenticated APIs. diff --git a/prds/new-plugin-arch/datastructure.md b/prds/new-plugin-arch/datastructure.md index 00f4d3bb..19019db2 100644 --- a/prds/new-plugin-arch/datastructure.md +++ b/prds/new-plugin-arch/datastructure.md @@ -21,6 +21,7 @@ RBAC design lives in: - config objects are first-class and versioned; - plugins link to config object identities, never directly to object versions; - plugin resolution always uses the latest active object version; +- marketplace resolution always uses the latest active plugin state; - latest version is derived from `config_object_version` ordering, not stored separately on `config_object`; - key config payload/data columns should be encrypted at rest; - friendly current metadata like `title` and `description` can remain plaintext for UI and search; @@ -161,6 +162,51 @@ Notes: - current implementation keeps one logical membership row per (`plugin_id`, `config_object_id`) and uses `removed_at` for soft removal/reactivation rather than append-only history rows; - if an object later becomes deleted, the membership row can remain while delivery skips that object. +### `marketplace` + +Stable top-level grouping row for plugins. + +Suggested columns: + +- `id` +- `organization_id` +- `name` +- `description` +- `status` +- `created_by_org_membership_id` +- `created_at` +- `updated_at` +- `deleted_at` nullable + +Notes: + +- an org can have multiple marketplaces; +- a marketplace groups plugins, not config objects directly; +- marketplace access can be the primary discovery/delivery control plane for curated plugin catalogs. + +### `marketplace_plugin` + +Membership join between marketplaces and plugins. + +Suggested columns: + +- `id` +- `marketplace_id` +- `plugin_id` +- `membership_source` (`manual`, `connector`, `api`, `system`) +- `created_by_org_membership_id` nullable +- `created_at` +- `removed_at` nullable + +Constraints: + +- unique active membership on (`marketplace_id`, `plugin_id`) + +Notes: + +- a plugin may appear in multiple marketplaces; +- current implementation should keep one logical membership row per (`marketplace_id`, `plugin_id`) and reactivate it by clearing `removed_at`. + ## Access and RBAC tables We want the same RBAC model across config objects, plugins, and connectors. @@ -196,6 +242,10 @@ Suggested columns: Same shape as plugin access, but scoped to `config_object_id`. +### `marketplace_access_grant` + +Same shape as plugin access, but scoped to `marketplace_id`. + ### `connector_instance_access_grant` Same shape as plugin access, but scoped to `connector_instance_id`. @@ -203,6 +253,7 @@ Same shape as plugin access, but scoped to `connector_instance_id`. RBAC note: - plugin delivery may be implemented primarily by plugin access grants; +- marketplace access may sit one level above plugin access and provide discovery/view inheritance into contained plugins; - if a team has access to a plugin, that is effectively the publish step. - config objects and plugins should be private by default; - sharing with the whole org should be represented as one org-wide grant, not per-user entries. @@ -457,6 +508,12 @@ Notes: 4. optionally update `config_object.updated_at` 5. optionally insert `plugin_config_object` +### Creating a new marketplace + +1. insert `marketplace` +2. insert initial `marketplace_access_grant` giving the creator `manager` +3. optionally insert `marketplace_plugin` rows for any initial plugin set + ### Connector sync updating an existing object 1. create `connector_sync_event` @@ -516,6 +573,9 @@ If we had to start implementation now, the minimum useful table set would be: - `plugin` - `plugin_config_object` - `plugin_access_grant` +- `marketplace` +- `marketplace_plugin` +- `marketplace_access_grant` - `connector_account` - `connector_instance` - `connector_target` diff --git a/prds/new-plugin-arch/plan.md b/prds/new-plugin-arch/plan.md index 983305a2..f5e0d297 100644 --- a/prds/new-plugin-arch/plan.md +++ b/prds/new-plugin-arch/plan.md @@ -54,6 +54,17 @@ Key idea: Plugins replace the current mental model of a hub. +### Marketplace + +A marketplace is an organization-scoped grouping of plugins. + +Key idea: + +- administrators can curate multiple marketplaces per org; +- each marketplace contains zero or more plugins; +- marketplace access controls discovery and delivery at a higher level than individual plugins; +- plugin-level access can still exist for direct sharing or exceptions. + ## Source Model Config objects may be created or updated from multiple source channels: @@ -226,12 +237,31 @@ Important: - plugin delivery resolves the latest version of each linked object; - delivery logic can decide whether deleted items are omitted from downloads. +### Marketplace layer + +Marketplaces likely need: + +- stable id; +- org id; +- metadata; +- lifecycle status; +- membership rows pointing to plugins; +- access grants for member/team/org-wide visibility. + +Important: + +- orgs can have multiple marketplaces; +- a plugin may belong to multiple marketplaces; +- marketplace membership should preserve history even when a plugin is later archived or removed; +- marketplace access should provide view/discovery access to included plugins without automatically granting plugin edit rights. + ## RBAC Direction RBAC should be consistent across: - config objects; - plugins; +- marketplaces; - connectors. We will likely need separate permission families for: @@ -239,7 +269,9 @@ We will likely need separate permission families for: - creating config objects manually; - editing cloud/import-managed objects; - creating plugins; +- creating marketplaces; - attaching objects to plugins; +- attaching plugins to marketplaces; - creating connector definitions; - configuring connector instances; - binding connector instances to plugins; @@ -301,6 +333,7 @@ Future: - skills become one config object type among many; - hubs disappear from the product model; - plugins become the administrator-authored deliverable; +- marketplaces become the higher-level catalog/grouping surface for plugins; - connectors can automatically populate plugins; - delivery/install rules move up from individual skills to plugin-aware distribution. @@ -315,6 +348,7 @@ That document currently captures: - the proposed `config_object` and `config_object_version` split; - plugin membership tables; +- marketplace membership tables; - RBAC table direction; - connector/account/instance/mapping/source-binding tables; - latest-version lookup strategy; @@ -385,6 +419,7 @@ Key current data-model decisions: - Treat `config_object_version` as the only source of truth for latest-version lookup in v1. - Do not add a version-number column in v1 unless product requirements emerge for human-facing revision labels. - Treat plugins as first-class entities with membership tables. +- Treat marketplaces as first-class entities that group plugins. - Keep source ownership explicit on every config object identity. - Model connectors as reusable integration definitions plus configured instances. - Store connector provenance richly enough to debug and reconcile webhook-driven ingestion. diff --git a/prds/new-plugin-arch/rbac.md b/prds/new-plugin-arch/rbac.md index 71029073..06c0baa3 100644 --- a/prds/new-plugin-arch/rbac.md +++ b/prds/new-plugin-arch/rbac.md @@ -10,6 +10,7 @@ We want one consistent RBAC model across: - config objects - plugins +- marketplaces - connector instances And we also need org-level permissions for who can: @@ -62,6 +63,7 @@ Resources: - one `config_object` - one `plugin` +- one `marketplace` - one `connector_instance` Current recommendation: @@ -115,6 +117,16 @@ Actions: - view delivery preview/resolved manifest - create release snapshot if releases exist +### Marketplaces + +Actions: + +- view metadata +- view included plugins +- edit metadata +- add/remove plugins +- manage marketplace access + ### Connector instances Actions: @@ -171,6 +183,7 @@ Candidate capabilities: - `config_object.create` - `plugin.create` +- `marketplace.create` - `connector_account.create` - `connector_instance.create` - `connector_sync.view_all` @@ -194,8 +207,9 @@ Current recommendation: - separate access tables with the same shape: - `config_object_access_grant` - - `plugin_access_grant` - - `connector_instance_access_grant` +- `plugin_access_grant` +- `marketplace_access_grant` +- `connector_instance_access_grant` Suggested shared columns: @@ -287,6 +301,29 @@ Needs: - `manager` on the plugin +### Marketplaces + +#### View + +Needs one of: + +- direct marketplace grant +- team marketplace grant +- marketplace has an active org-wide grant +- org admin implicit access + +#### Edit marketplace metadata / membership + +Needs: + +- `editor` or `manager` on the marketplace + +#### Manage marketplace access + +Needs: + +- `manager` on the marketplace + ### Connector instances #### View connector setup @@ -324,11 +361,13 @@ Current direction from the other docs: That means: - if team B has access to plugin A, that is effectively the publish step; +- if team B has access to marketplace M, they should be able to discover the plugins grouped inside it; - the delivery system should resolve access from plugin grants, not from low-level config-object grants alone. Current recommendation: - plugin delivery should primarily check plugin access; +- marketplace access can grant view/discovery access to included plugins, but should not automatically grant plugin edit rights; - config-object access should govern direct admin/editing access, not plugin delivery; - a user should have access to a config object if any of the following are true: - they are directly granted access to the object @@ -424,6 +463,7 @@ Current recommendation: - connector-created objects also default to the creator of the relevant connector/mapping action; - org owners/admins have implicit override access across all resources; - teams should only gain access through explicit grants, not automatic inheritance. +- marketplace inclusion should only inherit downward for view/discovery, never upward into edit/manage on plugins. Locked decision: @@ -456,6 +496,7 @@ The API surface in `prds/new-plugin-arch/admin-api.md` should assume: - object access endpoints manage `config_object_access_grant` - plugin access endpoints manage `plugin_access_grant` +- marketplace access endpoints manage `marketplace_access_grant` - connector instance access endpoints manage `connector_instance_access_grant` The API should also distinguish between: @@ -475,8 +516,10 @@ That distinction will help a lot with admin UX. 6. A user can access a config object if they are directly granted, team-granted, org-wide granted, or it is included in a plugin they can access. 7. Default grants for connector auto-created objects should go to the creator. 8. Config objects and plugins are private by default. -9. Sharing with the whole org should be represented as one org-wide grant, not per-user entries. -10. Member and team sharing should continue to use explicit grant rows. +9. Marketplaces are private by default. +10. Sharing with the whole org should be represented as one org-wide grant, not per-user entries. +11. Member and team sharing should continue to use explicit grant rows. +12. Marketplace access may grant view access to included plugins, but not plugin edit/manage access. ## Discussion questions