diff --git a/docs/docs.go b/docs/docs.go index a03be220..dd0af376 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -20710,6 +20710,194 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams": { + "get": { + "description": "Returns every downstream SSP allow-listed to subscribe to this offering\n(BCH-1342). An empty list means the offering has no allow-list set — any\ndownstream may subscribe (subject to the existing ssp:update and\ncontributor-role checks), the type-level default.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List an export offering's downstream-SSP allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Once an offering has at least one allow-list entry, only listed downstream\nSSPs may subscribe to it (BCH-1342) — enforced by a handler-level check in\nSubscribe, not by the PDP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "Add a downstream SSP to an export offering's allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + }, + { + "description": "Downstream SSP to allow", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscal.allowedDownstreamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams/{downstreamSspId}": { + "delete": { + "description": "If this removes the offering's last allow-list entry, the offering\nreverts to the type-level default (any downstream may subscribe,\nsubject to the existing ssp:update and contributor-role checks).", + "tags": [ + "SSP Export Offerings" + ], + "summary": "Remove a downstream SSP from an export offering's allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Downstream SSP ID", + "name": "downstreamSspId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/items": { "post": { "description": "Adds one offered capability (a control, optionally scoped to a statement, implemented by a component) to a draft export offering.", @@ -33731,6 +33919,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPExportOfferingAllowedDownstream" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-relational_SSPLeverageLink": { "type": "object", "properties": { @@ -35118,6 +35319,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.SSPExportOfferingAllowedDownstream" + } + ] + } + } + }, "handler.GenericDataResponse-relational_SSPExportOfferingItem": { "type": "object", "properties": { @@ -38201,6 +38415,14 @@ const docTemplate = `{ } } }, + "oscal.allowedDownstreamRequest": { + "type": "object", + "properties": { + "downstreamSspId": { + "type": "string" + } + } + }, "oscal.catalogOffering": { "type": "object", "properties": { @@ -45870,6 +46092,17 @@ const docTemplate = `{ } } }, + "relational.SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "downstreamSspId": { + "type": "string" + }, + "offeringId": { + "type": "string" + } + } + }, "relational.SSPExportOfferingItem": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 3ab0818f..fc8b2ac3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -20704,6 +20704,194 @@ ] } }, + "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams": { + "get": { + "description": "Returns every downstream SSP allow-listed to subscribe to this offering\n(BCH-1342). An empty list means the offering has no allow-list set — any\ndownstream may subscribe (subject to the existing ssp:update and\ncontributor-role checks), the type-level default.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List an export offering's downstream-SSP allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Once an offering has at least one allow-list entry, only listed downstream\nSSPs may subscribe to it (BCH-1342) — enforced by a handler-level check in\nSubscribe, not by the PDP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "Add a downstream SSP to an export offering's allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + }, + { + "description": "Downstream SSP to allow", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscal.allowedDownstreamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams/{downstreamSspId}": { + "delete": { + "description": "If this removes the offering's last allow-list entry, the offering\nreverts to the type-level default (any downstream may subscribe,\nsubject to the existing ssp:update and contributor-role checks).", + "tags": [ + "SSP Export Offerings" + ], + "summary": "Remove a downstream SSP from an export offering's allow-list", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Offering ID", + "name": "offeringId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Downstream SSP ID", + "name": "downstreamSspId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/export-offerings/{offeringId}/items": { "post": { "description": "Adds one offered capability (a control, optionally scoped to a statement, implemented by a component) to a draft export offering.", @@ -33725,6 +33913,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPExportOfferingAllowedDownstream" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-relational_SSPLeverageLink": { "type": "object", "properties": { @@ -35112,6 +35313,19 @@ } } }, + "handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.SSPExportOfferingAllowedDownstream" + } + ] + } + } + }, "handler.GenericDataResponse-relational_SSPExportOfferingItem": { "type": "object", "properties": { @@ -38195,6 +38409,14 @@ } } }, + "oscal.allowedDownstreamRequest": { + "type": "object", + "properties": { + "downstreamSspId": { + "type": "string" + } + } + }, "oscal.catalogOffering": { "type": "object", "properties": { @@ -45864,6 +46086,17 @@ } } }, + "relational.SSPExportOfferingAllowedDownstream": { + "type": "object", + "properties": { + "downstreamSspId": { + "type": "string" + }, + "offeringId": { + "type": "string" + } + } + }, "relational.SSPExportOfferingItem": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4170b0a7..945024b5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1093,6 +1093,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/relational.SSPExportOfferingAllowedDownstream' + type: array + meta: {} + type: object handler.GenericDataListResponse-relational_SSPLeverageLink: properties: data: @@ -1858,6 +1867,13 @@ definitions: - $ref: '#/definitions/relational.SSPExportOffering' description: Wrapped response data type: object + handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream: + properties: + data: + allOf: + - $ref: '#/definitions/relational.SSPExportOfferingAllowedDownstream' + description: Wrapped response data + type: object handler.GenericDataResponse-relational_SSPExportOfferingItem: properties: data: @@ -3912,6 +3928,11 @@ definitions: profileId: type: string type: object + oscal.allowedDownstreamRequest: + properties: + downstreamSspId: + type: string + type: object oscal.catalogOffering: properties: contentHash: @@ -9017,6 +9038,13 @@ definitions: version: type: integer type: object + relational.SSPExportOfferingAllowedDownstream: + properties: + downstreamSspId: + type: string + offeringId: + type: string + type: object relational.SSPExportOfferingItem: properties: componentUuid: @@ -24994,6 +25022,138 @@ paths: summary: Update an export offering tags: - SSP Export Offerings + /oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams: + get: + description: |- + Returns every downstream SSP allow-listed to subscribe to this offering + (BCH-1342). An empty list means the offering has no allow-list set — any + downstream may subscribe (subject to the existing ssp:update and + contributor-role checks), the type-level default. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Offering ID + in: path + name: offeringId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-relational_SSPExportOfferingAllowedDownstream' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List an export offering's downstream-SSP allow-list + tags: + - SSP Export Offerings + post: + consumes: + - application/json + description: |- + Once an offering has at least one allow-list entry, only listed downstream + SSPs may subscribe to it (BCH-1342) — enforced by a handler-level check in + Subscribe, not by the PDP. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Offering ID + in: path + name: offeringId + required: true + type: string + - description: Downstream SSP to allow + in: body + name: body + required: true + schema: + $ref: '#/definitions/oscal.allowedDownstreamRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-relational_SSPExportOfferingAllowedDownstream' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add a downstream SSP to an export offering's allow-list + tags: + - SSP Export Offerings + /oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams/{downstreamSspId}: + delete: + description: |- + If this removes the offering's last allow-list entry, the offering + reverts to the type-level default (any downstream may subscribe, + subject to the existing ssp:update and contributor-role checks). + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Offering ID + in: path + name: offeringId + required: true + type: string + - description: Downstream SSP ID + in: path + name: downstreamSspId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Remove a downstream SSP from an export offering's allow-list + tags: + - SSP Export Offerings /oscal/system-security-plans/{id}/export-offerings/{offeringId}/items: post: consumes: diff --git a/internal/api/handler/oscal/ssp_export_offering_allowlist_test.go b/internal/api/handler/oscal/ssp_export_offering_allowlist_test.go new file mode 100644 index 00000000..6832dd34 --- /dev/null +++ b/internal/api/handler/oscal/ssp_export_offering_allowlist_test.go @@ -0,0 +1,130 @@ +package oscal + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func newAllowedDownstreamsRequestContext(method string, sspID, offeringID uuid.UUID, extraParam, extraValue string, body string) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + var reader *bytes.Reader + if body != "" { + reader = bytes.NewReader([]byte(body)) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, "/", reader) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + if extraParam != "" { + ctx.SetParamNames("id", "offeringId", extraParam) + ctx.SetParamValues(sspID.String(), offeringID.String(), extraValue) + } else { + ctx.SetParamNames("id", "offeringId") + ctx.SetParamValues(sspID.String(), offeringID.String()) + } + return ctx, rec +} + +func TestAllowedDownstreamsAddListRemoveRoundTrip(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + addBody, err := json.Marshal(allowedDownstreamRequest{DownstreamSSPID: fx.downstreamSSPID.String()}) + require.NoError(t, err) + addCtx, addRec := newAllowedDownstreamsRequestContext(http.MethodPost, fx.upstreamSSPID, fx.offeringID, "", "", string(addBody)) + require.NoError(t, h.AddAllowedDownstream(addCtx)) + require.Equal(t, http.StatusCreated, addRec.Code) + + listCtx, listRec := newAllowedDownstreamsRequestContext(http.MethodGet, fx.upstreamSSPID, fx.offeringID, "", "", "") + require.NoError(t, h.ListAllowedDownstreams(listCtx)) + require.Equal(t, http.StatusOK, listRec.Code) + + var listResp handler.GenericDataListResponse[relational.SSPExportOfferingAllowedDownstream] + require.NoError(t, json.Unmarshal(listRec.Body.Bytes(), &listResp)) + require.Len(t, listResp.Data, 1) + require.Equal(t, fx.downstreamSSPID, listResp.Data[0].DownstreamSSPID) + + removeCtx, removeRec := newAllowedDownstreamsRequestContext(http.MethodDelete, fx.upstreamSSPID, fx.offeringID, "downstreamSspId", fx.downstreamSSPID.String(), "") + require.NoError(t, h.RemoveAllowedDownstream(removeCtx)) + require.Equal(t, http.StatusNoContent, removeRec.Code) + + var count int64 + require.NoError(t, db.Model(&relational.SSPExportOfferingAllowedDownstream{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestAllowedDownstreamsAddIsIdempotent(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + addBody, err := json.Marshal(allowedDownstreamRequest{DownstreamSSPID: fx.downstreamSSPID.String()}) + require.NoError(t, err) + + ctx1, rec1 := newAllowedDownstreamsRequestContext(http.MethodPost, fx.upstreamSSPID, fx.offeringID, "", "", string(addBody)) + require.NoError(t, h.AddAllowedDownstream(ctx1)) + require.Equal(t, http.StatusCreated, rec1.Code) + + ctx2, rec2 := newAllowedDownstreamsRequestContext(http.MethodPost, fx.upstreamSSPID, fx.offeringID, "", "", string(addBody)) + require.NoError(t, h.AddAllowedDownstream(ctx2)) + require.Equal(t, http.StatusCreated, rec2.Code) + + var count int64 + require.NoError(t, db.Model(&relational.SSPExportOfferingAllowedDownstream{}).Count(&count).Error) + require.Equal(t, int64(1), count) +} + +func TestAllowedDownstreamsRejectsInvalidDownstreamID(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + addBody, err := json.Marshal(allowedDownstreamRequest{DownstreamSSPID: "not-a-uuid"}) + require.NoError(t, err) + ctx, rec := newAllowedDownstreamsRequestContext(http.MethodPost, fx.upstreamSSPID, fx.offeringID, "", "", string(addBody)) + require.NoError(t, h.AddAllowedDownstream(ctx)) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestAllowedDownstreamsRejectsInvalidOfferingID(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + ctx, rec := newAllowedDownstreamsRequestContext(http.MethodGet, fx.upstreamSSPID, uuid.New(), "", "", "") + require.NoError(t, h.ListAllowedDownstreams(ctx)) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRemoveAllowedDownstreamNotFoundReturns404(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + ctx, rec := newAllowedDownstreamsRequestContext(http.MethodDelete, fx.upstreamSSPID, fx.offeringID, "downstreamSspId", uuid.New().String(), "") + require.NoError(t, h.RemoveAllowedDownstream(ctx)) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRemoveAllowedDownstreamInvalidUUIDReturns400(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + ctx, rec := newAllowedDownstreamsRequestContext(http.MethodDelete, fx.upstreamSSPID, fx.offeringID, "downstreamSspId", "not-a-uuid", "") + require.NoError(t, h.RemoveAllowedDownstream(ctx)) + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 1833c8e9..64332865 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -16,6 +16,7 @@ import ( "github.com/labstack/echo/v4" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" @@ -248,6 +249,9 @@ func (h *SSPExportOfferingHandler) RegisterNested(api *echo.Group, guard middlew api.DELETE("/:id/export-offerings/:offeringId/items/:itemId", h.DeleteItem, guard.Do(authz.ActionExport)) api.POST("/:id/export-offerings/:offeringId/publish", h.Publish, guard.Do(authz.ActionExport)) api.PATCH("/:id/export-offerings/:offeringId/status", h.UpdateOfferingStatus, guard.Do(authz.ActionExport)) + api.GET("/:id/export-offerings/:offeringId/allowed-downstreams", h.ListAllowedDownstreams, guard.Do(authz.ActionExport)) + api.POST("/:id/export-offerings/:offeringId/allowed-downstreams", h.AddAllowedDownstream, guard.Do(authz.ActionExport)) + api.DELETE("/:id/export-offerings/:offeringId/allowed-downstreams/:downstreamSspId", h.RemoveAllowedDownstream, guard.Do(authz.ActionExport)) } // Register mounts the top-level, cross-SSP read-only catalog: list and get any @@ -694,6 +698,140 @@ func (h *SSPExportOfferingHandler) DeleteItem(ctx echo.Context) error { return ctx.NoContent(http.StatusNoContent) } +type allowedDownstreamRequest struct { + DownstreamSSPID string `json:"downstreamSspId"` +} + +func (r allowedDownstreamRequest) validate() error { + if _, err := uuid.Parse(r.DownstreamSSPID); err != nil { + return fmt.Errorf("downstreamSspId must be a valid UUID") + } + return nil +} + +// ListAllowedDownstreams godoc +// +// @Summary List an export offering's downstream-SSP allow-list +// @Description Returns every downstream SSP allow-listed to subscribe to this offering +// @Description (BCH-1342). An empty list means the offering has no allow-list set — any +// @Description downstream may subscribe (subject to the existing ssp:update and +// @Description contributor-role checks), the type-level default. +// @Tags SSP Export Offerings +// @Produce json +// @Param id path string true "SSP ID" +// @Param offeringId path string true "Offering ID" +// @Success 200 {object} handler.GenericDataListResponse[relational.SSPExportOfferingAllowedDownstream] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams [get] +func (h *SSPExportOfferingHandler) ListAllowedDownstreams(ctx echo.Context) error { + offering, ok := h.resolveOfferingForSSP(ctx) + if !ok { + return nil + } + + var allowed []relational.SSPExportOfferingAllowedDownstream + if err := h.db.Where("offering_id = ?", offering.ID).Find(&allowed).Error; err != nil { + h.sugar.Errorf("Failed to list offering allow-list: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[relational.SSPExportOfferingAllowedDownstream]{Data: allowed}) +} + +// AddAllowedDownstream godoc +// +// @Summary Add a downstream SSP to an export offering's allow-list +// @Description Once an offering has at least one allow-list entry, only listed downstream +// @Description SSPs may subscribe to it (BCH-1342) — enforced by a handler-level check in +// @Description Subscribe, not by the PDP. +// @Tags SSP Export Offerings +// @Accept json +// @Produce json +// @Param id path string true "SSP ID" +// @Param offeringId path string true "Offering ID" +// @Param body body allowedDownstreamRequest true "Downstream SSP to allow" +// @Success 201 {object} handler.GenericDataResponse[relational.SSPExportOfferingAllowedDownstream] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams [post] +func (h *SSPExportOfferingHandler) AddAllowedDownstream(ctx echo.Context) error { + offering, ok := h.resolveOfferingForSSP(ctx) + if !ok { + return nil + } + + var req allowedDownstreamRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := req.validate(); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + downstreamSSPID, err := uuid.Parse(req.DownstreamSSPID) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + allowed := relational.SSPExportOfferingAllowedDownstream{ + OfferingID: *offering.ID, + DownstreamSSPID: downstreamSSPID, + } + if err := h.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&allowed).Error; err != nil { + h.sugar.Errorf("Failed to add offering allow-list entry: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[relational.SSPExportOfferingAllowedDownstream]{Data: allowed}) +} + +// RemoveAllowedDownstream godoc +// +// @Summary Remove a downstream SSP from an export offering's allow-list +// @Description If this removes the offering's last allow-list entry, the offering +// @Description reverts to the type-level default (any downstream may subscribe, +// @Description subject to the existing ssp:update and contributor-role checks). +// @Tags SSP Export Offerings +// @Param id path string true "SSP ID" +// @Param offeringId path string true "Offering ID" +// @Param downstreamSspId path string true "Downstream SSP ID" +// @Success 204 +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/export-offerings/{offeringId}/allowed-downstreams/{downstreamSspId} [delete] +func (h *SSPExportOfferingHandler) RemoveAllowedDownstream(ctx echo.Context) error { + offering, ok := h.resolveOfferingForSSP(ctx) + if !ok { + return nil + } + + downstreamSspIdParam := ctx.Param("downstreamSspId") + downstreamSSPID, err := uuid.Parse(downstreamSspIdParam) + if err != nil { + h.sugar.Warnw("Invalid downstream SSP id", "downstreamSspId", downstreamSspIdParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + result := h.db.Where("offering_id = ? AND downstream_ssp_id = ?", offering.ID, downstreamSSPID). + Delete(&relational.SSPExportOfferingAllowedDownstream{}) + if result.Error != nil { + h.sugar.Errorf("Failed to remove offering allow-list entry: %v", result.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(result.Error)) + } + if result.RowsAffected == 0 { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("allow-list entry not found"))) + } + + return ctx.NoContent(http.StatusNoContent) +} + // Publish godoc // // @Summary Publish an export offering diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index d365e5fe..fbbb8179 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -39,6 +39,13 @@ var errDuplicateLeverageLink = errors.New("already subscribed to this provided-u // correct client action, not treated as a 500. var errLeverageLinkNoLongerDrifted = errors.New("leverage link is no longer drifted") +// errDownstreamNotAllowed signals that the offering's allow-list (BCH-1342) rejects +// downstreamSSPID. Checked inside the subscribe transaction (via tx, not h.db) so the +// allow-list read and the write it gates are atomic — reading it before the transaction +// opened would let a concurrent allow-list change race the write undetected. Mapped to +// 403, same as the original pre-transaction check. +var errDownstreamNotAllowed = errors.New("downstream not allow-listed for this offering") + // isUniqueViolation reports whether err is a Postgres unique-constraint violation. GORM's // ErrDuplicatedKey translation is unreliable here (same issue noted in // internal/api/handler/groups.go's isUniqueViolation), so the driver code is inspected @@ -212,6 +219,33 @@ func (h *SSPLeverageHandler) authorizeDownstreamUpdate(ctx echo.Context, sspID u return decision.Allow, nil } +// isDownstreamAllowed reports whether downstreamSSPID may subscribe to offeringID +// (BCH-1342). A handler-level check, not a PDP/manifest-driven one — see this ticket's +// scoping decision (tasks.md) for why: BCH-1319's C1/C2 resource-attribute resolution +// isn't honoured by any shipped driver yet, so the offering's allow-list is enforced +// directly here, mirroring authorizeDownstreamUpdate's existing ad-hoc pattern. An +// offering with zero allow-list rows keeps the type-level default (any downstream +// permitted) for backwards compatibility. +func isDownstreamAllowed(db *gorm.DB, offeringID, downstreamSSPID uuid.UUID) (bool, error) { + var total int64 + if err := db.Model(&relational.SSPExportOfferingAllowedDownstream{}). + Where("offering_id = ?", offeringID). + Count(&total).Error; err != nil { + return false, fmt.Errorf("failed to count offering allow-list: %w", err) + } + if total == 0 { + return true, nil + } + + var matching int64 + if err := db.Model(&relational.SSPExportOfferingAllowedDownstream{}). + Where("offering_id = ? AND downstream_ssp_id = ?", offeringID, downstreamSSPID). + Count(&matching).Error; err != nil { + return false, fmt.Errorf("failed to check offering allow-list membership: %w", err) + } + return matching > 0, nil +} + // findOrCreateThisSystemComponent finds the downstream's placeholder "this-system" // component, creating one if none exists — not every SSP has one, and there's no // guarantee the subscribing downstream does either. @@ -513,6 +547,14 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { var links []relational.SSPLeverageLink if err := h.db.Transaction(func(tx *gorm.DB) error { + downstreamAllowed, err := isDownstreamAllowed(tx, offeringID, downstreamSSPID) + if err != nil { + return err + } + if !downstreamAllowed { + return errDownstreamNotAllowed + } + thisSystemComponent, err := findOrCreateThisSystemComponent(tx, *downstream.SystemImplementation.ID) if err != nil { return err @@ -617,6 +659,9 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { if errors.Is(err, errDuplicateLeverageLink) { return ctx.JSON(http.StatusConflict, api.NewError(err)) } + if errors.Is(err, errDownstreamNotAllowed) { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } h.sugar.Errorf("Failed to subscribe to export offering: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } diff --git a/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go new file mode 100644 index 00000000..7b4b2609 --- /dev/null +++ b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go @@ -0,0 +1,147 @@ +//go:build integration + +package oscal + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/tests" +) + +// SubscribeAllowlistIntegrationSuite exercises BCH-1342's ticket AC end to end, through a +// real cedar-driven PEP: a downstream SSP not on an offering's allow-list is denied +// subscribe (403) even holding the contributor role (which alone would otherwise pass +// both the ssp-export-offering:subscribe and ssp:update checks); an allow-listed +// downstream succeeds; an offering with no allow-list set keeps today's type-level +// default (any downstream may subscribe). +type SubscribeAllowlistIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server +} + +func TestSubscribeAllowlistIntegrationSuite(t *testing.T) { + suite.Run(t, new(SubscribeAllowlistIntegrationSuite)) +} + +func (suite *SubscribeAllowlistIntegrationSuite) SetupTest() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.server = newCedarServer(&suite.IntegrationTestSuite) +} + +// publishedOfferingFixture creates an upstream SSP, publishes a one-item offering on it as +// contributorToken, and returns (upstreamSSPID, offeringID, itemID). +func (suite *SubscribeAllowlistIntegrationSuite) publishedOfferingFixture(contributorToken string) (upstreamSSPID, offeringID, itemID string) { + componentUUID := uuid.New().String() + ssp := minimalSSP(componentUUID) + rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", "/api/oscal/system-security-plans", contributorToken, ssp) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + upstreamSSPID = ssp.UUID + + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings", upstreamSSPID), + contributorToken, + map[string]string{"title": "Allow-list test offering", "description": "one leverageable control"}, + ) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var created handler.GenericDataResponse[relational.SSPExportOffering] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &created)) + offeringID = created.Data.ID.String() + + itemPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSPID, offeringID) + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", itemPath, contributorToken, map[string]any{ + "controlId": "ac-1", "componentUuid": componentUUID, "providedUuid": uuid.New().String(), + }) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var item handler.GenericDataResponse[relational.SSPExportOfferingItem] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &item)) + itemID = item.Data.ID.String() + + publishPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/publish", upstreamSSPID, offeringID) + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", publishPath, contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + + return upstreamSSPID, offeringID, itemID +} + +func (suite *SubscribeAllowlistIntegrationSuite) createDownstreamSSP(contributorToken string) string { + ssp := minimalSSP(uuid.New().String()) + rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", "/api/oscal/system-security-plans", contributorToken, ssp) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + return ssp.UUID +} + +func (suite *SubscribeAllowlistIntegrationSuite) subscribeRequestBody(downstreamSSPID, itemID string) map[string]any { + return map[string]any{ + "downstreamSspId": downstreamSSPID, + "leveragedAuthorization": map[string]string{ + "title": "Trust", + "partyUuid": uuid.New().String(), + }, + "items": []map[string]any{ + {"itemId": itemID, "satisfiedResponsibilityUuids": []string{}}, + }, + } +} + +func (suite *SubscribeAllowlistIntegrationSuite) TestNonAllowListedDownstreamDeniedEvenWithContributorRole() { + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "upstream-curator@example.com", "contributor") + upstreamSSPID, offeringID, itemID := suite.publishedOfferingFixture(contributorToken) + + allowedDownstreamSSPID := suite.createDownstreamSSP(contributorToken) + addAllowlistPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/allowed-downstreams", upstreamSSPID, offeringID) + rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", addAllowlistPath, contributorToken, + map[string]string{"downstreamSspId": allowedDownstreamSSPID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + // A different downstream, held by a user who also has the contributor role (so + // ssp-export-offering:subscribe and ssp:update both pass) — the allow-list is the only + // thing that should block this request. + downstreamContributorToken := createRoledUser(&suite.IntegrationTestSuite, "downstream-contributor@example.com", "contributor") + nonListedDownstreamSSPID := suite.createDownstreamSSP(downstreamContributorToken) + + subscribePath := fmt.Sprintf("/api/oscal/ssp-export-offerings/%s/subscribe", offeringID) + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", subscribePath, downstreamContributorToken, + suite.subscribeRequestBody(nonListedDownstreamSSPID, itemID)) + suite.Require().Equal(http.StatusForbidden, rec.Code, rec.Body.String()) +} + +func (suite *SubscribeAllowlistIntegrationSuite) TestAllowListedDownstreamCanSubscribe() { + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "upstream-curator2@example.com", "contributor") + upstreamSSPID, offeringID, itemID := suite.publishedOfferingFixture(contributorToken) + + downstreamContributorToken := createRoledUser(&suite.IntegrationTestSuite, "downstream-contributor2@example.com", "contributor") + downstreamSSPID := suite.createDownstreamSSP(downstreamContributorToken) + + addAllowlistPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/allowed-downstreams", upstreamSSPID, offeringID) + rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", addAllowlistPath, contributorToken, + map[string]string{"downstreamSspId": downstreamSSPID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + subscribePath := fmt.Sprintf("/api/oscal/ssp-export-offerings/%s/subscribe", offeringID) + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", subscribePath, downstreamContributorToken, + suite.subscribeRequestBody(downstreamSSPID, itemID)) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) +} + +func (suite *SubscribeAllowlistIntegrationSuite) TestNoAllowListSetKeepsTypeLevelDefault() { + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "upstream-curator3@example.com", "contributor") + _, offeringID, itemID := suite.publishedOfferingFixture(contributorToken) + + downstreamContributorToken := createRoledUser(&suite.IntegrationTestSuite, "downstream-contributor3@example.com", "contributor") + downstreamSSPID := suite.createDownstreamSSP(downstreamContributorToken) + + // No allowed-downstreams entries added for this offering at all. + subscribePath := fmt.Sprintf("/api/oscal/ssp-export-offerings/%s/subscribe", offeringID) + rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", subscribePath, downstreamContributorToken, + suite.subscribeRequestBody(downstreamSSPID, itemID)) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) +} diff --git a/internal/api/handler/oscal/ssp_leverage_allowlist_test.go b/internal/api/handler/oscal/ssp_leverage_allowlist_test.go new file mode 100644 index 00000000..15fd1824 --- /dev/null +++ b/internal/api/handler/oscal/ssp_leverage_allowlist_test.go @@ -0,0 +1,97 @@ +package oscal + +import ( + "net/http" + "testing" + + "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestIsDownstreamAllowedDefaultsTrueWhenNoAllowListSet(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + + allowed, err := isDownstreamAllowed(db, fx.offeringID, fx.downstreamSSPID) + require.NoError(t, err) + require.True(t, allowed) +} + +func TestIsDownstreamAllowedTrueWhenListed(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: fx.downstreamSSPID, + }).Error) + + allowed, err := isDownstreamAllowed(db, fx.offeringID, fx.downstreamSSPID) + require.NoError(t, err) + require.True(t, allowed) +} + +func TestIsDownstreamAllowedFalseWhenNotListed(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + // Allow-list has an entry, but not for fx.downstreamSSPID — some other SSP. + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: uuid.New(), + }).Error) + + allowed, err := isDownstreamAllowed(db, fx.offeringID, fx.downstreamSSPID) + require.NoError(t, err) + require.False(t, allowed) +} + +func TestSubscribeRejectsNonAllowListedDownstream(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: uuid.New(), + }).Error) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + + body := subscribeBody(fx.downstreamSSPID, fx.itemID, fx.respAID) + ctx, e, rec := newSubscribeRequestContext(fx.offeringID, body) + err := h.Subscribe(ctx) + require.Error(t, err) + e.HTTPErrorHandler(err, ctx) + require.Equal(t, http.StatusForbidden, rec.Code) + + var count int64 + require.NoError(t, db.Model(&relational.SSPLeverageLink{}).Count(&count).Error) + require.Zero(t, count, "a rejected subscribe must not create a leverage link") +} + +func TestSubscribeAllowsAllowListedDownstream(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: fx.downstreamSSPID, + }).Error) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + + body := subscribeBody(fx.downstreamSSPID, fx.itemID, fx.respAID) + ctx, _, rec := newSubscribeRequestContext(fx.offeringID, body) + require.NoError(t, h.Subscribe(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) +} + +func TestSubscribeAllowsAnyDownstreamWhenNoAllowListSet(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + + body := subscribeBody(fx.downstreamSSPID, fx.itemID, fx.respAID) + ctx, _, rec := newSubscribeRequestContext(fx.offeringID, body) + require.NoError(t, h.Subscribe(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) +} diff --git a/internal/api/handler/oscal/ssp_leverage_unit_test.go b/internal/api/handler/oscal/ssp_leverage_unit_test.go index 74121a38..2a6bb344 100644 --- a/internal/api/handler/oscal/ssp_leverage_unit_test.go +++ b/internal/api/handler/oscal/ssp_leverage_unit_test.go @@ -31,6 +31,7 @@ func newSSPLeverageTestDB(t *testing.T) *gorm.DB { &relational.ControlImplementationResponsibility{}, &relational.SSPExportOffering{}, &relational.SSPExportOfferingItem{}, + &relational.SSPExportOfferingAllowedDownstream{}, &relational.SSPLeverageLink{}, &relational.SystemSecurityPlan{}, &relational.SystemImplementation{}, diff --git a/internal/authz/manifest.yaml b/internal/authz/manifest.yaml index 7e2a201a..1d7ae34a 100644 --- a/internal/authz/manifest.yaml +++ b/internal/authz/manifest.yaml @@ -101,9 +101,19 @@ resources: # implementations. Deliberately a separate resource rather than inheriting the SSP # root (unlike the nested OSCAL sub-documents noted above) because published offerings # are meant to be independently discoverable/subscribable (Phase 2+) by parties who may - # not hold any grant on the upstream ssp resource itself. `subscribe` is declared now for - # the Phase 2 downstream-subscribe vocabulary; no route enforces it yet. + # not hold any grant on the upstream ssp resource itself. `subscribe` is enforced + # type-level (any contributor) via guard.Do(authz.ActionSubscribe) on the Subscribe + # route (BCH-1338 Phase 2). actions: [read, create, update, delete, subscribe] + attributes: + allowed_downstreams: set # C1 (BCH-1342) — the offering's downstream-SSP + # subscribe allow-list. Declared here for documentation/future engine wiring only: + # enforced today by a handler-level check in Subscribe (ssp_leverage.go's + # isDownstreamAllowed), not by the PDP/Cedar policy layer, since BCH-1319's C1/C2 + # attribute resolution isn't honoured by any shipped driver yet (see this file's + # header comment). An offering with no allow-list rows keeps the type-level + # default (any downstream may subscribe, subject to the existing ssp:update and + # contributor-role checks) — backwards compatible. assessment-plan: actions: [read, create, update, delete] attributes: diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 75c29343..c4149204 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -85,6 +85,7 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { &relational.SSPProfile{}, &relational.SSPExportOffering{}, &relational.SSPExportOfferingItem{}, + &relational.SSPExportOfferingAllowedDownstream{}, &relational.SSPLeverageLink{}, &relational.AuthorizationBoundary{}, &relational.NetworkArchitecture{}, @@ -770,6 +771,7 @@ func MigrateDown(db *gorm.DB) error { &relational.ImplementedRequirement{}, &relational.ControlImplementation{}, &relational.SSPLeverageLink{}, + &relational.SSPExportOfferingAllowedDownstream{}, &relational.SSPExportOfferingItem{}, &relational.SSPExportOffering{}, &relational.SystemSecurityPlan{}, diff --git a/internal/service/relational/ssp_offerings.go b/internal/service/relational/ssp_offerings.go index 0230454e..ab101e61 100644 --- a/internal/service/relational/ssp_offerings.go +++ b/internal/service/relational/ssp_offerings.go @@ -67,3 +67,21 @@ type SSPExportOfferingItem struct { func (SSPExportOfferingItem) TableName() string { return "ssp_export_offering_items" } + +// SSPExportOfferingAllowedDownstream is one entry in an offering's downstream-SSP +// allow-list (BCH-1342): if an offering has at least one row here, only the listed +// downstream SSPs may subscribe to it; an offering with zero rows keeps today's +// type-level default (any downstream permitted, subject to the existing ssp:update and +// contributor-role checks). Enforced by a handler-level check in Subscribe +// (ssp_leverage.go), not by the PDP — see internal/authz/manifest.yaml's +// allowed_downstreams attribute comment for why. +type SSPExportOfferingAllowedDownstream struct { + OfferingID uuid.UUID `json:"offeringId" gorm:"type:uuid;primaryKey"` + DownstreamSSPID uuid.UUID `json:"downstreamSspId" gorm:"type:uuid;primaryKey"` + + Offering *SSPExportOffering `json:"-" gorm:"foreignKey:OfferingID;references:ID;constraint:OnDelete:CASCADE"` +} + +func (SSPExportOfferingAllowedDownstream) TableName() string { + return "ssp_export_offering_allowed_downstreams" +} diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index 0df7154c..a538bdb3 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -89,6 +89,7 @@ func (t *TestMigrator) Up() error { &relational.SystemSecurityPlan{}, &relational.SSPExportOffering{}, &relational.SSPExportOfferingItem{}, + &relational.SSPExportOfferingAllowedDownstream{}, &relational.SSPLeverageLink{}, &relational.AuthorizationBoundary{}, &relational.NetworkArchitecture{}, @@ -429,6 +430,7 @@ func (t *TestMigrator) Down() error { &relational.ImplementedRequirement{}, &relational.ControlImplementation{}, &relational.SSPLeverageLink{}, + &relational.SSPExportOfferingAllowedDownstream{}, &relational.SSPExportOfferingItem{}, &relational.SSPExportOffering{}, &relational.SystemSecurityPlan{},