From 62a67d26eae2e971aecfad77f3a1817c173c90fd Mon Sep 17 00:00:00 2001 From: Reuben Lifshay Date: Mon, 25 Nov 2024 13:36:15 -0800 Subject: [PATCH 01/18] BED-4894: Posture API Spec (#953) * chore: add quantization related parameters and schemas to openapi * chore: add openapi spec for attack path finding-trends * chore: add openapi spec for risk posture posture-history * chore: partial implementation of partition_by for data-quality-stats * chore: revert data-quality-stats openapi specs * chore: add completeness data to posture-history and add unpartioned data format * chore: update finding-trends openapi spec to use snake case * chore: remove partitioning from posture-history openapi spec * chore: update return data type for posture-history endpoint spec * chore: update to use environment_id instead of domain_id --- packages/go/openapi/doc/openapi.json | 257 ++++++++++++++++++ packages/go/openapi/src/openapi.yaml | 4 + ...k-paths.environment.id.finding-trends.yaml | 97 +++++++ ...e.environment.id.posture-history.type.yaml | 101 +++++++ 4 files changed, 459 insertions(+) create mode 100644 packages/go/openapi/src/paths/attack-paths.environment.id.finding-trends.yaml create mode 100644 packages/go/openapi/src/paths/risk-posture.environment.id.posture-history.type.yaml diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index 9b608944b4..bfa128e800 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -12170,6 +12170,125 @@ } } }, + "/api/v2/domains/{environment_id}/finding-trends": { + "parameters": [ + { + "$ref": "#/components/parameters/header.prefer" + }, + { + "name": "environment_id", + "description": "Environment ID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "FindingTrendsForEnvironment", + "summary": "List finding trends", + "description": "Lists findings and their changes in between two dates for an environment", + "tags": [ + "Attack Paths", + "Enterprise" + ], + "parameters": [ + { + "name": "start", + "description": "Beginning datetime of range (inclusive) in RFC-3339 format; Defaults to current datetime minus 30 days", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "end", + "description": "Ending datetime of range (exclusive) in RFC-3339 format; Defaults to current datetime", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/api.response.time-window" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "environment_id": { + "type": "string" + }, + "finding": { + "type": "string" + }, + "composite_risk": { + "type": "number", + "format": "double" + }, + "finding_count_start": { + "type": "integer" + }, + "finding_count_end": { + "type": "integer" + } + } + } + }, + "total_finding_count_start": { + "type": "integer" + }, + "total_finding_count_end": { + "type": "integer" + } + } + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + } + } + }, "/api/v2/attack-path-types": { "parameters": [ { @@ -13071,6 +13190,134 @@ } } }, + "/api/v2/domains/{environment_id}/posture-history/{data_type}": { + "parameters": [ + { + "$ref": "#/components/parameters/header.prefer" + }, + { + "name": "environment_id", + "description": "Environment ID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "data_type", + "description": "The type of posture data to return", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/data_type" + } + } + ], + "get": { + "operationId": "PostureHistoryForEnvironment", + "summary": "Get Posture History", + "description": "Gets posture data count changes over a time period", + "tags": [ + "Risk Posture", + "Enterprise" + ], + "parameters": [ + { + "name": "start", + "description": "Beginning datetime of range (inclusive) in RFC-3339 format; Defaults to current datetime minus 30 days", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "end", + "description": "Ending datetime of range (exclusive) in RFC-3339 format; Defaults to current datetime", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/api.response.time-window" + }, + { + "type": "object", + "properties": { + "data_type": { + "$ref": "#/components/schemas/data_type" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "value": { + "type": "number", + "format": "double", + "readOnly": true + } + } + } + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + } + }, + "components": { + "schemas": { + "data_type": { + "type": "string", + "enum": [ + "findings", + "exposure", + "assets", + "session_completeness", + "group_completeness" + ] + } + } + } + }, "/api/v2/meta/{object_id}": { "parameters": [ { @@ -16026,6 +16273,16 @@ } } ] + }, + "data_type": { + "type": "string", + "enum": [ + "findings", + "exposure", + "assets", + "session_completeness", + "group_completeness" + ] } }, "responses": { diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml index 1ce32385d8..e2914bdb9b 100644 --- a/packages/go/openapi/src/openapi.yaml +++ b/packages/go/openapi/src/openapi.yaml @@ -642,6 +642,8 @@ paths: # attack paths /api/v2/domains/{domain_id}/attack-path-findings: $ref: './paths/attack-paths.domains.id.attack-path-findings.yaml' + /api/v2/domains/{environment_id}/finding-trends: + $ref: './paths/attack-paths.environment.id.finding-trends.yaml' /api/v2/attack-path-types: $ref: './paths/attack-paths.attack-path-types.yaml' /api/v2/attack-paths: @@ -658,6 +660,8 @@ paths: # risk posture /api/v2/posture-stats: $ref: './paths/risk-posture.posture-stats.yaml' + /api/v2/domains/{environment_id}/posture-history/{data_type}: + $ref: './paths/risk-posture.environment.id.posture-history.type.yaml' # meta entity /api/v2/meta/{object_id}: diff --git a/packages/go/openapi/src/paths/attack-paths.environment.id.finding-trends.yaml b/packages/go/openapi/src/paths/attack-paths.environment.id.finding-trends.yaml new file mode 100644 index 0000000000..548b255215 --- /dev/null +++ b/packages/go/openapi/src/paths/attack-paths.environment.id.finding-trends.yaml @@ -0,0 +1,97 @@ +# Copyright 2024 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +parameters: + - $ref: './../parameters/header.prefer.yaml' + - name: environment_id + description: Environment ID + in: path + required: true + schema: + type: string +get: + operationId: FindingTrendsForEnvironment + summary: List finding trends + description: Lists findings and their changes in between two dates for an environment + tags: + - Attack Paths + - Enterprise + parameters: + # - name: sort_by + # description: Sortable columns are composite_risk, start_count, end_count, change. + # in: query + # schema: + # $ref: './../schemas/api.params.query.sort-by.yaml' + - name: start + description: Beginning datetime of range (inclusive) in RFC-3339 format; Defaults + to current datetime minus 30 days + in: query + schema: + type: string + format: date-time + - name: end + description: Ending datetime of range (exclusive) in RFC-3339 format; Defaults + to current datetime + in: query + schema: + type: string + format: date-time + responses: + 200: + description: OK + content: + application/json: + schema: + allOf: + - $ref: './../schemas/api.response.time-window.yaml' + - type: object + properties: + data: + type: object + properties: + findings: + type: array + items: + type: object + properties: + environment_id: + type: string + finding: + type: string + composite_risk: + type: number + format: double + finding_count_start: + type: integer + finding_count_end: + type: integer + total_finding_count_start: + type: integer + total_finding_count_end: + type: integer + + 400: + $ref: './../responses/bad-request.yaml' + 401: + $ref: './../responses/unauthorized.yaml' + 403: + $ref: './../responses/forbidden.yaml' + 404: + $ref: './../responses/not-found.yaml' + 429: + $ref: './../responses/too-many-requests.yaml' + 500: + $ref: './../responses/internal-server-error.yaml' diff --git a/packages/go/openapi/src/paths/risk-posture.environment.id.posture-history.type.yaml b/packages/go/openapi/src/paths/risk-posture.environment.id.posture-history.type.yaml new file mode 100644 index 0000000000..01fb001306 --- /dev/null +++ b/packages/go/openapi/src/paths/risk-posture.environment.id.posture-history.type.yaml @@ -0,0 +1,101 @@ +# Copyright 2024 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +parameters: + - $ref: './../parameters/header.prefer.yaml' + - name: environment_id + description: Environment ID + in: path + required: true + schema: + type: string + - name: data_type + description: The type of posture data to return + in: path + required: true + schema: + $ref: '#/components/schemas/data_type' +get: + operationId: PostureHistoryForEnvironment + summary: Get Posture History + description: Gets posture data count changes over a time period + tags: + - Risk Posture + - Enterprise + parameters: + - name: start + description: Beginning datetime of range (inclusive) in RFC-3339 format; Defaults + to current datetime minus 30 days + in: query + schema: + type: string + format: date-time + - name: end + description: Ending datetime of range (exclusive) in RFC-3339 format; Defaults + to current datetime + in: query + schema: + type: string + format: date-time + responses: + 200: + description: OK + content: + application/json: + schema: + allOf: + - $ref: './../schemas/api.response.time-window.yaml' + - type: object + properties: + data_type: + $ref: '#/components/schemas/data_type' + data: + type: array + items: + type: object + properties: + date: + type: string + format: date-time + readOnly: true + value: + type: number + format: double + readOnly: true + + 400: + $ref: './../responses/bad-request.yaml' + 401: + $ref: './../responses/unauthorized.yaml' + 403: + $ref: './../responses/forbidden.yaml' + 404: + $ref: './../responses/not-found.yaml' + 429: + $ref: './../responses/too-many-requests.yaml' + 500: + $ref: './../responses/internal-server-error.yaml' + +components: + schemas: + data_type: + type: string + enum: + - findings + - exposure + - assets + - session_completeness + - group_completeness From 43421c9e54de9eaafcf8a985fa43e4e764f76691 Mon Sep 17 00:00:00 2001 From: Reuben Lifshay Date: Mon, 25 Nov 2024 13:37:16 -0800 Subject: [PATCH 02/18] chore: add constants for posture api endpoints (#977) --- cmd/api/src/api/constant.go | 1 + cmd/api/src/api/v2/attack_path.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cmd/api/src/api/constant.go b/cmd/api/src/api/constant.go index 6d9837ac13..d39612a4cd 100644 --- a/cmd/api/src/api/constant.go +++ b/cmd/api/src/api/constant.go @@ -48,6 +48,7 @@ const ( URIPathVariableAssetGroupSelectorID = "asset_group_selector_id" URIPathVariableAttackPathID = "attack_path_id" URIPathVariableClientID = "client_id" + URIPathVariableDataType = "data_type" URIPathVariableDomainID = "domain_id" URIPathVariableEventID = "event_id" URIPathVariableFeatureID = "feature_id" diff --git a/cmd/api/src/api/v2/attack_path.go b/cmd/api/src/api/v2/attack_path.go index 279f75c812..aae337251b 100644 --- a/cmd/api/src/api/v2/attack_path.go +++ b/cmd/api/src/api/v2/attack_path.go @@ -27,6 +27,7 @@ const ( ErrorNoFindingType = "no finding type specified" ErrorInvalidFindingType = "invalid finding type specified: %v" ErrorInvalidRFC3339 = "invalid RFC-3339 datetime format: %v" + ErrorNoDataType = "no data type specified in url" ) type RiskAcceptRequest struct { From 48f69499c93496758d7344fb4163bfea1503ee47 Mon Sep 17 00:00:00 2001 From: Brandon Shearin Date: Mon, 25 Nov 2024 17:06:06 -0500 Subject: [PATCH 03/18] Bed 4405: update openAPI docs for list findings (#974) * update docs? dont know if this worked * openapi updated for list findings * chore: prepare-for-codereview run --------- Co-authored-by: Alyx Holms --- cmd/api/src/api/v2/auth/saml.go | 2 +- cmd/api/src/database/db.go | 1 - packages/go/openapi/doc/openapi.json | 14 +------------- .../src/paths/attack-paths.domains.id.details.yaml | 6 +----- .../go/openapi/src/schemas/model.list-finding.yaml | 6 ------ 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/cmd/api/src/api/v2/auth/saml.go b/cmd/api/src/api/v2/auth/saml.go index 5f2da6cc92..86596c1374 100644 --- a/cmd/api/src/api/v2/auth/saml.go +++ b/cmd/api/src/api/v2/auth/saml.go @@ -31,7 +31,7 @@ import ( "github.com/specterops/bloodhound/log" "github.com/specterops/bloodhound/mediatypes" "github.com/specterops/bloodhound/src/api" - "github.com/specterops/bloodhound/src/api/v2" + v2 "github.com/specterops/bloodhound/src/api/v2" "github.com/specterops/bloodhound/src/auth" "github.com/specterops/bloodhound/src/ctx" "github.com/specterops/bloodhound/src/model" diff --git a/cmd/api/src/database/db.go b/cmd/api/src/database/db.go index 1dcd20feec..b324a00b33 100644 --- a/cmd/api/src/database/db.go +++ b/cmd/api/src/database/db.go @@ -124,7 +124,6 @@ type Database interface { DeleteAuthSecret(ctx context.Context, authSecret model.AuthSecret) error InitializeSecretAuth(ctx context.Context, adminUser model.User, authSecret model.AuthSecret) (model.Installation, error) - // SSO SSOProviderData OIDCProviderData diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index bfa128e800..c7ec56d2ec 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -12721,7 +12721,7 @@ "AcceptedUntil": "2024-08-28T21:21:40.845Z", "ImpactPercentage": 12, "ImpactCount": 2, - "ExposurePercentage": 24, + "ExposurePercentage": 0.24, "ExposureCount": 4, "Severity": "high", "Accepted": true @@ -12810,8 +12810,6 @@ "accepted_until": "2024-08-28T21:42:18.844Z", "ImpactPercentage": 12, "ImpactCount": 2, - "ExposurePercentage": 24, - "ExposureCount": 4, "Severity": "high", "Accepted": true } @@ -12846,8 +12844,6 @@ "accepted_until": "2024-08-28T21:42:18.844Z", "ImpactPercentage": 0, "ImpactCount": 0, - "ExposurePercentage": 0, - "ExposureCount": 0, "Severity": "", "Accepted": true } @@ -16177,14 +16173,6 @@ "type": "integer", "format": "int64" }, - "ExposurePercentage": { - "type": "number", - "format": "double" - }, - "ExposureCount": { - "type": "integer", - "format": "int64" - }, "Severity": { "type": "string", "enum": [ diff --git a/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml b/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml index 7fd539d196..4164185b4a 100644 --- a/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml +++ b/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml @@ -177,7 +177,7 @@ get: AcceptedUntil: "2024-08-28T21:21:40.845Z", ImpactPercentage: 12, ImpactCount: 2, - ExposurePercentage: 24, + ExposurePercentage: .24, ExposureCount: 4, Severity: 'high', Accepted: true, @@ -262,8 +262,6 @@ get: accepted_until: "2024-08-28T21:42:18.844Z", ImpactPercentage: 12, ImpactCount: 2, - ExposurePercentage: 24, - ExposureCount: 4, Severity: 'high', Accepted: true } @@ -296,8 +294,6 @@ get: accepted_until: "2024-08-28T21:42:18.844Z", ImpactPercentage: 0, ImpactCount: 0, - ExposurePercentage: 0, - ExposureCount: 0, Severity: '', Accepted: true } diff --git a/packages/go/openapi/src/schemas/model.list-finding.yaml b/packages/go/openapi/src/schemas/model.list-finding.yaml index f283768896..c767525ba0 100644 --- a/packages/go/openapi/src/schemas/model.list-finding.yaml +++ b/packages/go/openapi/src/schemas/model.list-finding.yaml @@ -40,12 +40,6 @@ allOf: ImpactCount: type: integer format: int64 - ExposurePercentage: - type: number - format: double - ExposureCount: - type: integer - format: int64 Severity: type: string enum: From 6f7f4cace6ac2648de2b9edbe1593af11cadeecc Mon Sep 17 00:00:00 2001 From: Ben Waples Date: Mon, 25 Nov 2024 22:55:14 -0800 Subject: [PATCH 04/18] Bed 4812 (#970) * wip: new posture api * chore: cleanup trends client api * chore: bump doodle * refactor: standard SortOrder * chore: install new modules * chore: add new colors * chore: add license back * fix: undo formatting changes * fix: PostureFindingTrend to match spec * chore: remove severity_label * chore: change trends API endpoint and response type --- cmd/ui/src/styles/constants.ts | 2 ++ .../SSOProviderTable.test.tsx | 3 ++- .../SSOProviderTable/SSOProviderTable.tsx | 25 ++++++++++--------- .../bh-shared-ui/src/utils/types.ts | 4 +++ .../SSOConfiguration/SSOConfiguration.tsx | 16 ++++++------ .../js-client-library/src/client.ts | 4 +-- .../js-client-library/src/responses.ts | 14 +++++++---- 7 files changed, 40 insertions(+), 28 deletions(-) diff --git a/cmd/ui/src/styles/constants.ts b/cmd/ui/src/styles/constants.ts index 567f7d451d..c638286625 100644 --- a/cmd/ui/src/styles/constants.ts +++ b/cmd/ui/src/styles/constants.ts @@ -26,5 +26,7 @@ export const colors = { red: '#e52d2d', green: '#03b603', softRed: '#FF7E79', + softRed2: '#E15851', softGreen: '#A8D08D', + softGreen2: '#02C577', }; diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx index c08e929987..99771b6dd3 100644 --- a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx @@ -17,6 +17,7 @@ import userEvent from '@testing-library/user-event'; import { OIDCProviderInfo, SAMLProviderInfo, SSOProvider } from 'js-client-library'; import { render, screen } from '../../test-utils'; +import { SortOrder } from '../../utils'; import SSOProviderTable from './SSOProviderTable'; const samlProvider: SSOProvider = { @@ -66,7 +67,7 @@ describe('SSOProviderTable', () => { it('should sort by type', async () => { const user = userEvent.setup(); - let typeSortOrder: 'asc' | 'desc' | undefined; + let typeSortOrder: SortOrder; const onToggleTypeSortOrder = () => { if (!typeSortOrder || typeSortOrder === 'desc') { typeSortOrder = 'asc'; diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx index 6ffbc02ea4..b2bfb55de9 100644 --- a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx +++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx @@ -14,9 +14,18 @@ // // SPDX-License-Identifier: Apache-2.0 +import { Button } from '@bloodhoundenterprise/doodleui'; +import { faEllipsisVertical, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { - Skeleton, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + MenuProps, Paper, + Skeleton, Table, TableBody, TableCell, @@ -24,20 +33,12 @@ import { TableHead, TableRow, TableSortLabel, - IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - MenuProps, useTheme, } from '@mui/material'; -import { Button } from '@bloodhoundenterprise/doodleui'; -import { faEllipsisVertical, faTrash } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import withStyles from '@mui/styles/withStyles'; -import { FC, useState, MouseEventHandler } from 'react'; import { SSOProvider } from 'js-client-library'; +import { FC, MouseEventHandler, useState } from 'react'; +import { SortOrder } from '../../utils'; const StyledMenu = withStyles({ paper: { @@ -107,7 +108,7 @@ const SSOProviderTable: FC<{ onDeleteSSOProvider: (ssoProviderId: SSOProvider['id']) => void; onClickSSOProvider: (ssoProviderId: SSOProvider['id']) => void; onToggleTypeSortOrder: () => void; - typeSortOrder?: 'asc' | 'desc'; + typeSortOrder?: SortOrder; }> = ({ ssoProviders, loading, onDeleteSSOProvider, onClickSSOProvider, typeSortOrder, onToggleTypeSortOrder }) => { const theme = useTheme(); return ( diff --git a/packages/javascript/bh-shared-ui/src/utils/types.ts b/packages/javascript/bh-shared-ui/src/utils/types.ts index 3d66f80c1f..9d7e4d515b 100644 --- a/packages/javascript/bh-shared-ui/src/utils/types.ts +++ b/packages/javascript/bh-shared-ui/src/utils/types.ts @@ -20,3 +20,7 @@ export type DeepPartial = T extends object [P in keyof T]?: DeepPartial; } : T; + +export type SortOrder = 'asc' | 'desc' | undefined; + +export type ValueOf = T[keyof T]; diff --git a/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx b/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx index 4ac4b6adbd..96868063c7 100644 --- a/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx +++ b/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx @@ -14,26 +14,26 @@ // // SPDX-License-Identifier: Apache-2.0 -import { useState, FC, useMemo, ChangeEvent } from 'react'; import { faSearch } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Box, Grid, Typography, Paper, TextField, useTheme } from '@mui/material'; -import { useMutation, useQuery } from 'react-query'; +import { Box, Grid, Paper, TextField, Typography, useTheme } from '@mui/material'; import { CreateOIDCProviderRequest, SSOProvider } from 'js-client-library'; -import { apiClient } from '../../utils'; +import { ChangeEvent, FC, useMemo, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; import { + ConfirmationDialog, + CreateMenu, CreateSAMLProviderDialog, CreateSAMLProviderFormInputs, - CreateMenu, - ConfirmationDialog, DocumentationLinks, - SSOProviderTable, PageWithTitle, SSOProviderInfoPanel, + SSOProviderTable, } from '../../components'; import CreateOIDCProviderDialog from '../../components/CreateOIDCProviderDialog'; import { useFeatureFlag } from '../../hooks'; import { useNotifications } from '../../providers'; +import { SortOrder, apiClient } from '../../utils'; const SSOConfiguration: FC = () => { /* Hooks */ @@ -46,7 +46,7 @@ const SSOConfiguration: FC = () => { const [dialogOpen, setDialogOpen] = useState<'SAML' | 'OIDC' | 'DELETE' | ''>(''); const [nameFilter, setNameFilter] = useState(''); const [createProviderError, setCreateProviderError] = useState(''); - const [typeSortOrder, setTypeSortOrder] = useState<'asc' | 'desc' | undefined>(); + const [typeSortOrder, setTypeSortOrder] = useState(); const listSSOProvidersQuery = useQuery(['listSSOProviders'], ({ signal }) => apiClient.listSSOProviders({ signal }).then((res) => res.data.data) diff --git a/packages/javascript/js-client-library/src/client.ts b/packages/javascript/js-client-library/src/client.ts index 6548478cf3..cca06147b4 100644 --- a/packages/javascript/js-client-library/src/client.ts +++ b/packages/javascript/js-client-library/src/client.ts @@ -30,9 +30,9 @@ import { ListFileIngestJobsResponse, ListFileTypesForIngestResponse, PaginatedResponse, - PostureResponse, PostureFindingTrendsResponse, PostureHistoryResponse, + PostureResponse, SavedQuery, StartFileIngestResponse, UpdateConfigurationResponse, @@ -287,7 +287,7 @@ class BHEAPIClient { options?: types.RequestOptions ) => { return this.baseClient.get( - `/api/v2/finding-trends/${environmentId}`, + `/api/v2/domains/${environmentId}/finding-trends`, Object.assign( { start: start?.toISOString(), diff --git a/packages/javascript/js-client-library/src/responses.ts b/packages/javascript/js-client-library/src/responses.ts index 1805dd52c6..432b722ce7 100644 --- a/packages/javascript/js-client-library/src/responses.ts +++ b/packages/javascript/js-client-library/src/responses.ts @@ -101,14 +101,18 @@ type PostureStat = TimestampFields & { export type PostureResponse = PaginatedResponse; type PostureFindingTrend = { + environment_id: string; finding: string; - start_count: number; - end_count: number; - severity: number; - severity_label: string; + finding_count_start: number; + finding_count_end: number; + composite_risk: number; }; -export type PostureFindingTrendsResponse = { findings: PostureFindingTrend[]; total_start: number; total_end: number }; +export type PostureFindingTrendsResponse = TimeWindowedResponse<{ + findings: PostureFindingTrend[]; + total_finding_count_start: number; + total_finding_count_end: number; +}>; type PostureHistoryAggregatedData = { date: string; From 6eb3479bde06f04329f0366d60a800792439326e Mon Sep 17 00:00:00 2001 From: Cody Bentley Date: Tue, 26 Nov 2024 08:28:40 -0700 Subject: [PATCH 05/18] Posture page api updates (#982) * feat: bed-4894 posture page - un-document api spec for now - added new invalid time range api error message * chore: generate new api spec --- cmd/api/src/api/error.go | 1 + packages/go/openapi/doc/openapi.json | 257 --------------------------- packages/go/openapi/src/openapi.yaml | 8 +- 3 files changed, 5 insertions(+), 261 deletions(-) diff --git a/cmd/api/src/api/error.go b/cmd/api/src/api/error.go index 15fb8365cb..867bd0b7e1 100644 --- a/cmd/api/src/api/error.go +++ b/cmd/api/src/api/error.go @@ -54,6 +54,7 @@ const ( ErrorResponseDetailsOTPInvalid = "one time password is invalid" ErrorResponseDetailsResourceNotFound = "resource not found" ErrorResponseDetailsToBeforeFrom = "to time cannot be before from time" + ErrorResponseDetailsTimeRangeInvalid = "time range provided is invalid" ErrorResponseDetailsToMalformed = "to parameter should be formatted as RFC3339 i.e 2021-04-21T07:20:50.52Z" ErrorResponseMultipleCollectionScopesProvided = "may only scope collection by exactly one of OU, Domain, or All Trusted Domains" ErrorResponsePayloadUnmarshalError = "error unmarshalling JSON payload" diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index c7ec56d2ec..1d87a80b68 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -12170,125 +12170,6 @@ } } }, - "/api/v2/domains/{environment_id}/finding-trends": { - "parameters": [ - { - "$ref": "#/components/parameters/header.prefer" - }, - { - "name": "environment_id", - "description": "Environment ID", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "operationId": "FindingTrendsForEnvironment", - "summary": "List finding trends", - "description": "Lists findings and their changes in between two dates for an environment", - "tags": [ - "Attack Paths", - "Enterprise" - ], - "parameters": [ - { - "name": "start", - "description": "Beginning datetime of range (inclusive) in RFC-3339 format; Defaults to current datetime minus 30 days", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "end", - "description": "Ending datetime of range (exclusive) in RFC-3339 format; Defaults to current datetime", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/api.response.time-window" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "findings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "environment_id": { - "type": "string" - }, - "finding": { - "type": "string" - }, - "composite_risk": { - "type": "number", - "format": "double" - }, - "finding_count_start": { - "type": "integer" - }, - "finding_count_end": { - "type": "integer" - } - } - } - }, - "total_finding_count_start": { - "type": "integer" - }, - "total_finding_count_end": { - "type": "integer" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/bad-request" - }, - "401": { - "$ref": "#/components/responses/unauthorized" - }, - "403": { - "$ref": "#/components/responses/forbidden" - }, - "404": { - "$ref": "#/components/responses/not-found" - }, - "429": { - "$ref": "#/components/responses/too-many-requests" - }, - "500": { - "$ref": "#/components/responses/internal-server-error" - } - } - } - }, "/api/v2/attack-path-types": { "parameters": [ { @@ -13186,134 +13067,6 @@ } } }, - "/api/v2/domains/{environment_id}/posture-history/{data_type}": { - "parameters": [ - { - "$ref": "#/components/parameters/header.prefer" - }, - { - "name": "environment_id", - "description": "Environment ID", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "data_type", - "description": "The type of posture data to return", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/data_type" - } - } - ], - "get": { - "operationId": "PostureHistoryForEnvironment", - "summary": "Get Posture History", - "description": "Gets posture data count changes over a time period", - "tags": [ - "Risk Posture", - "Enterprise" - ], - "parameters": [ - { - "name": "start", - "description": "Beginning datetime of range (inclusive) in RFC-3339 format; Defaults to current datetime minus 30 days", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "end", - "description": "Ending datetime of range (exclusive) in RFC-3339 format; Defaults to current datetime", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/api.response.time-window" - }, - { - "type": "object", - "properties": { - "data_type": { - "$ref": "#/components/schemas/data_type" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "value": { - "type": "number", - "format": "double", - "readOnly": true - } - } - } - } - } - } - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/bad-request" - }, - "401": { - "$ref": "#/components/responses/unauthorized" - }, - "403": { - "$ref": "#/components/responses/forbidden" - }, - "404": { - "$ref": "#/components/responses/not-found" - }, - "429": { - "$ref": "#/components/responses/too-many-requests" - }, - "500": { - "$ref": "#/components/responses/internal-server-error" - } - } - }, - "components": { - "schemas": { - "data_type": { - "type": "string", - "enum": [ - "findings", - "exposure", - "assets", - "session_completeness", - "group_completeness" - ] - } - } - } - }, "/api/v2/meta/{object_id}": { "parameters": [ { @@ -16261,16 +16014,6 @@ } } ] - }, - "data_type": { - "type": "string", - "enum": [ - "findings", - "exposure", - "assets", - "session_completeness", - "group_completeness" - ] } }, "responses": { diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml index e2914bdb9b..e2072fe2e5 100644 --- a/packages/go/openapi/src/openapi.yaml +++ b/packages/go/openapi/src/openapi.yaml @@ -642,8 +642,8 @@ paths: # attack paths /api/v2/domains/{domain_id}/attack-path-findings: $ref: './paths/attack-paths.domains.id.attack-path-findings.yaml' - /api/v2/domains/{environment_id}/finding-trends: - $ref: './paths/attack-paths.environment.id.finding-trends.yaml' +# /api/v2/domains/{environment_id}/finding-trends: +# $ref: './paths/attack-paths.environment.id.finding-trends.yaml' /api/v2/attack-path-types: $ref: './paths/attack-paths.attack-path-types.yaml' /api/v2/attack-paths: @@ -660,8 +660,8 @@ paths: # risk posture /api/v2/posture-stats: $ref: './paths/risk-posture.posture-stats.yaml' - /api/v2/domains/{environment_id}/posture-history/{data_type}: - $ref: './paths/risk-posture.environment.id.posture-history.type.yaml' +# /api/v2/domains/{environment_id}/posture-history/{data_type}: +# $ref: './paths/risk-posture.environment.id.posture-history.type.yaml' # meta entity /api/v2/meta/{object_id}: From e6bfe458bfc5eac8535a69aaa5124c6a768a6ace Mon Sep 17 00:00:00 2001 From: Eli K Miller Date: Tue, 26 Nov 2024 09:38:18 -0600 Subject: [PATCH 06/18] chore: sets the `updated_posture_page` feature flag to true in the 6.3.0 migration file (#979) --- cmd/api/src/database/migration/migrations/v6.3.0.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/api/src/database/migration/migrations/v6.3.0.sql b/cmd/api/src/database/migration/migrations/v6.3.0.sql index cb51ecbaab..18a382483c 100644 --- a/cmd/api/src/database/migration/migrations/v6.3.0.sql +++ b/cmd/api/src/database/migration/migrations/v6.3.0.sql @@ -27,3 +27,6 @@ ALTER TABLE ONLY saml_providers -- Update root_uri_version to default to 2 or "/v2/sso/" for newly created saml providers ALTER TABLE ONLY saml_providers ALTER COLUMN root_uri_version SET DEFAULT 2; + +-- Set the `updated_posture_page` feature flag to true +UPDATE feature_flags SET enabled = true WHERE key = 'updated_posture_page'; From ec9a83cc7a605301c42c681f4381b66f80277ea2 Mon Sep 17 00:00:00 2001 From: Ulises Rangel Date: Tue, 26 Nov 2024 13:19:05 -0600 Subject: [PATCH 07/18] chore: update docs (#980) --- packages/go/openapi/doc/openapi.json | 6 +++--- .../src/paths/attack-paths.domains.id.details.yaml | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index 1d87a80b68..75c1fe361b 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -12369,7 +12369,7 @@ "get": { "operationId": "ListDomainAttackPathsDetails", "summary": "List domain attack paths details", - "description": "Lists detailed data about attack paths for a domain. \n\n__Note:__ __Note:__ `ImpactCount`, `ImpactPercentage`, `ExposureCount`, `ExposurePercentage` and `Severity` will have a value other than zero when butterfly analysis is enabled.\n", + "description": "Lists detailed data about attack paths for a domain.\n\n__Note:__ __Note:__ `ImpactCount`, `ImpactPercentage`, `ExposureCount`, `ExposurePercentage` and `Severity` will have a value other than zero when butterfly analysis is enabled.\n", "tags": [ "Attack Paths", "Enterprise" @@ -12600,7 +12600,7 @@ "DomainSID": "string", "PrincipalHash": "string", "AcceptedUntil": "2024-08-28T21:21:40.845Z", - "ImpactPercentage": 12, + "ImpactPercentage": 0.12, "ImpactCount": 2, "ExposurePercentage": 0.24, "ExposureCount": 4, @@ -12689,7 +12689,7 @@ "additionalProp3": {} }, "accepted_until": "2024-08-28T21:42:18.844Z", - "ImpactPercentage": 12, + "ImpactPercentage": 0.12, "ImpactCount": 2, "Severity": "high", "Accepted": true diff --git a/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml b/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml index 4164185b4a..3b31efcdf8 100644 --- a/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml +++ b/packages/go/openapi/src/paths/attack-paths.domains.id.details.yaml @@ -26,8 +26,8 @@ get: operationId: ListDomainAttackPathsDetails summary: List domain attack paths details description: | - Lists detailed data about attack paths for a domain. - + Lists detailed data about attack paths for a domain. + __Note:__ __Note:__ `ImpactCount`, `ImpactPercentage`, `ExposureCount`, `ExposurePercentage` and `Severity` will have a value other than zero when butterfly analysis is enabled. tags: - Attack Paths @@ -175,14 +175,14 @@ get: DomainSID: string, PrincipalHash: string, AcceptedUntil: "2024-08-28T21:21:40.845Z", - ImpactPercentage: 12, + ImpactPercentage: 0.12, ImpactCount: 2, - ExposurePercentage: .24, + ExposurePercentage: 0.24, ExposureCount: 4, Severity: 'high', Accepted: true, } - ] + ] Metatree Relationship Finding: summary: "Metatree Relationship Finding" description: "When the butterfly analysis feature flag is off and metatree is running, impact count/percentage and exposure count/percentage will have a value of zero." @@ -234,7 +234,7 @@ get: Accepted: true } ] - Butterfly List Finding: + Butterfly List Finding: summary: "Butterfly List Finding" description: "When the butterfly analysis feature flag is on, impact count/percentage and exposure count/percentage will have a value other than zero." value: @@ -260,7 +260,7 @@ get: additionalProp3: {} }, accepted_until: "2024-08-28T21:42:18.844Z", - ImpactPercentage: 12, + ImpactPercentage: 0.12, ImpactCount: 2, Severity: 'high', Accepted: true From be350863357cfb74c9a8c9074a040df394172d89 Mon Sep 17 00:00:00 2001 From: mistahj67 <26472282+mistahj67@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:10:44 -0700 Subject: [PATCH 08/18] Bed-5068 feat: Add PATCH endpoint to update sso providers (#975) --- cmd/api/src/api/registration/v2.go | 1 + cmd/api/src/api/v2/auth/oidc.go | 48 ++++++-- cmd/api/src/api/v2/auth/oidc_test.go | 107 +++++++++++++++--- cmd/api/src/api/v2/auth/saml.go | 73 +++++++++++- cmd/api/src/api/v2/auth/sso.go | 21 ++++ cmd/api/src/auth/saml.go | 36 ++++-- cmd/api/src/database/auth_test.go | 35 +++--- cmd/api/src/database/mocks/db.go | 51 ++++++++- cmd/api/src/database/oidc_providers.go | 29 +++++ cmd/api/src/database/oidc_providers_test.go | 39 +++++-- cmd/api/src/database/samlproviders.go | 26 ++++- cmd/api/src/database/sso_providers.go | 43 +++++++ cmd/api/src/model/audit.go | 2 + cmd/api/src/model/samlprovider.go | 12 +- cmd/api/src/utils/validation/url_validator.go | 4 +- 15 files changed, 450 insertions(+), 77 deletions(-) diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index 5c599bed58..440745a73d 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -59,6 +59,7 @@ func registerV2Auth(resources v2.Resources, routerInst *router.Router, permissio routerInst.GET("/api/v2/sso-providers", managementResource.ListAuthProviders), routerInst.POST("/api/v2/sso-providers/oidc", managementResource.CreateOIDCProvider).CheckFeatureFlag(resources.DB, appcfg.FeatureOIDCSupport).RequirePermissions(permissions.AuthManageProviders), routerInst.DELETE(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.DeleteSSOProvider).RequirePermissions(permissions.AuthManageProviders), + routerInst.PATCH(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.UpdateSSOProvider).RequirePermissions(permissions.AuthManageProviders), routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/login", api.URIPathVariableSSOProviderSlug), managementResource.SSOLoginHandler), routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/metadata", api.URIPathVariableSSOProviderSlug), managementResource.ServeMetadata), routerInst.PathPrefix(fmt.Sprintf("/api/v2/sso/{%s}/callback", api.URIPathVariableSSOProviderSlug), http.HandlerFunc(managementResource.SSOCallbackHandler)), diff --git a/cmd/api/src/api/v2/auth/oidc.go b/cmd/api/src/api/v2/auth/oidc.go index 67541408b5..76bd695ccd 100644 --- a/cmd/api/src/api/v2/auth/oidc.go +++ b/cmd/api/src/api/v2/auth/oidc.go @@ -31,25 +31,57 @@ import ( "golang.org/x/oauth2" ) -// CreateOIDCProviderRequest represents the body of the CreateOIDCProvider endpoint -type CreateOIDCProviderRequest struct { +// UpsertOIDCProviderRequest represents the body of create & update provider endpoints +type UpsertOIDCProviderRequest struct { Name string `json:"name" validate:"required"` Issuer string `json:"issuer" validate:"url"` ClientID string `json:"client_id" validate:"required"` } +// UpdateOIDCProviderRequest updates an OIDC provider, support for only partial payloads +func (s ManagementResource) UpdateOIDCProviderRequest(response http.ResponseWriter, request *http.Request, ssoProvider model.SSOProvider) { + var upsertReq UpsertOIDCProviderRequest + + if ssoProvider.OIDCProvider == nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response) + } else if err := api.ReadJSONRequestPayloadLimited(&upsertReq, request); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + } else { + if upsertReq.Name != "" { + ssoProvider.Name = upsertReq.Name + } + + if upsertReq.ClientID != "" { + ssoProvider.OIDCProvider.ClientID = upsertReq.ClientID + } + + if upsertReq.Issuer != "" { + if err := validation.ValidUrl(upsertReq.Issuer); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "issuer url is invalid", request), response) + return + } + + ssoProvider.OIDCProvider.Issuer = upsertReq.Issuer + } + + if oidcProvider, err := s.db.UpdateOIDCProvider(request.Context(), ssoProvider); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), oidcProvider, http.StatusOK, response) + } + } +} + // CreateOIDCProvider creates an OIDC provider entry given a valid request func (s ManagementResource) CreateOIDCProvider(response http.ResponseWriter, request *http.Request) { - var ( - createRequest = CreateOIDCProviderRequest{} - ) + var upsertReq UpsertOIDCProviderRequest - if err := api.ReadJSONRequestPayloadLimited(&createRequest, request); err != nil { + if err := api.ReadJSONRequestPayloadLimited(&upsertReq, request); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) - } else if validated := validation.Validate(createRequest); validated != nil { + } else if validated := validation.Validate(upsertReq); validated != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, validated.Error(), request), response) } else { - if oidcProvider, err := s.db.CreateOIDCProvider(request.Context(), createRequest.Name, createRequest.Issuer, createRequest.ClientID); err != nil { + if oidcProvider, err := s.db.CreateOIDCProvider(request.Context(), upsertReq.Name, upsertReq.Issuer, upsertReq.ClientID); err != nil { api.HandleDatabaseError(request, response, err) } else { api.WriteBasicResponse(request.Context(), oidcProvider, http.StatusCreated, response) diff --git a/cmd/api/src/api/v2/auth/oidc_test.go b/cmd/api/src/api/v2/auth/oidc_test.go index c3b5733c86..72c88982ac 100644 --- a/cmd/api/src/api/v2/auth/oidc_test.go +++ b/cmd/api/src/api/v2/auth/oidc_test.go @@ -23,15 +23,13 @@ import ( "github.com/specterops/bloodhound/src/api/v2/apitest" "github.com/specterops/bloodhound/src/api/v2/auth" + "github.com/specterops/bloodhound/src/database" "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/utils/test" "go.uber.org/mock/gomock" ) func TestManagementResource_CreateOIDCProvider(t *testing.T) { - const ( - url = "/api/v2/sso/providers/oidc" - ) var ( mockCtrl = gomock.NewController(t) resources, mockDB = apitest.NewAuthManagementResource(mockCtrl) @@ -45,9 +43,7 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { }, nil) test.Request(t). - WithMethod(http.MethodPost). - WithURL(url). - WithBody(auth.CreateOIDCProviderRequest{ + WithBody(auth.UpsertOIDCProviderRequest{ Name: "Bloodhound gang", Issuer: "https://localhost/auth", ClientID: "bloodhound", @@ -59,9 +55,6 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { t.Run("error parsing body request", func(t *testing.T) { test.Request(t). - WithMethod(http.MethodPost). - WithURL(url). - WithBody(""). OnHandlerFunc(resources.CreateOIDCProvider). Require(). ResponseStatusCode(http.StatusBadRequest) @@ -69,9 +62,7 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { t.Run("error validating request field", func(t *testing.T) { test.Request(t). - WithMethod(http.MethodPost). - WithURL(url). - WithBody(auth.CreateOIDCProviderRequest{ + WithBody(auth.UpsertOIDCProviderRequest{ Name: "test", Issuer: "1234:not:a:url", ClientID: "bloodhound", @@ -82,12 +73,10 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { }) t.Run("error invalid Issuer", func(t *testing.T) { - request := auth.CreateOIDCProviderRequest{ + request := auth.UpsertOIDCProviderRequest{ Issuer: "12345:bloodhound", } test.Request(t). - WithMethod(http.MethodPost). - WithURL(url). WithBody(request). OnHandlerFunc(resources.CreateOIDCProvider). Require(). @@ -98,9 +87,7 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { mockDB.EXPECT().CreateOIDCProvider(gomock.Any(), "test", "https://localhost/auth", "bloodhound").Return(model.OIDCProvider{}, fmt.Errorf("error")) test.Request(t). - WithMethod(http.MethodPost). - WithURL(url). - WithBody(auth.CreateOIDCProviderRequest{ + WithBody(auth.UpsertOIDCProviderRequest{ Name: "test", Issuer: "https://localhost/auth", ClientID: "bloodhound", @@ -110,3 +97,87 @@ func TestManagementResource_CreateOIDCProvider(t *testing.T) { ResponseStatusCode(http.StatusInternalServerError) }) } + +func TestManagementResource_UpdateOIDCProvider(t *testing.T) { + var ( + mockCtrl = gomock.NewController(t) + resources, mockDB = apitest.NewAuthManagementResource(mockCtrl) + baseProvider = model.SSOProvider{ + Type: model.SessionAuthProviderOIDC, + Name: "Gotham Net", + OIDCProvider: &model.OIDCProvider{ + ClientID: "gotham-net", + Issuer: "https://gotham.net", + }, + } + urlParams = map[string]string{"sso_provider_id": "1"} + ) + defer mockCtrl.Finish() + + t.Run("successfully update an OIDCProvider", func(t *testing.T) { + mockDB.EXPECT().GetSSOProviderById(gomock.Any(), int32(1)).Return(baseProvider, nil) + mockDB.EXPECT().UpdateOIDCProvider(gomock.Any(), gomock.Any()) + + test.Request(t). + WithURLPathVars(urlParams). + WithBody(auth.UpsertOIDCProviderRequest{ + Name: "Gotham Net 2", + Issuer: "https://gotham-2.net", + ClientID: "gotham-net-2", + }). + OnHandlerFunc(resources.UpdateSSOProvider). + Require(). + ResponseStatusCode(http.StatusOK) + }) + + t.Run("error not found while updating an unknown OIDCProvider", func(t *testing.T) { + mockDB.EXPECT().GetSSOProviderById(gomock.Any(), int32(1)).Return(model.SSOProvider{}, database.ErrNotFound) + + test.Request(t). + WithURLPathVars(urlParams). + OnHandlerFunc(resources.UpdateSSOProvider). + Require(). + ResponseStatusCode(http.StatusNotFound) + }) + + t.Run("error parsing body request", func(t *testing.T) { + mockDB.EXPECT().GetSSOProviderById(gomock.Any(), int32(1)).Return(baseProvider, nil) + + test.Request(t). + WithURLPathVars(urlParams). + OnHandlerFunc(resources.UpdateSSOProvider). + Require(). + ResponseStatusCode(http.StatusBadRequest) + }) + + t.Run("error validating request field", func(t *testing.T) { + mockDB.EXPECT().GetSSOProviderById(gomock.Any(), int32(1)).Return(baseProvider, nil) + + test.Request(t). + WithURLPathVars(urlParams). + WithBody(auth.UpsertOIDCProviderRequest{ + Name: "test", + Issuer: "1234:not:a:url", + ClientID: "bloodhound", + }). + OnHandlerFunc(resources.UpdateSSOProvider). + Require(). + ResponseStatusCode(http.StatusBadRequest) + }) + + t.Run("error creating oidc provider db entry", func(t *testing.T) { + mockDB.EXPECT().GetSSOProviderById(gomock.Any(), int32(1)).Return(baseProvider, nil) + mockDB.EXPECT().UpdateOIDCProvider(gomock.Any(), gomock.Any()).Return(model.OIDCProvider{}, fmt.Errorf("error")) + + test.Request(t). + WithURLPathVars(urlParams). + WithBody(auth.UpsertOIDCProviderRequest{ + Name: "test", + Issuer: "https://localhost/auth", + ClientID: "bloodhound", + }). + OnHandlerFunc(resources.UpdateSSOProvider). + Require(). + ResponseStatusCode(http.StatusInternalServerError) + }) +} diff --git a/cmd/api/src/api/v2/auth/saml.go b/cmd/api/src/api/v2/auth/saml.go index 86596c1374..89ac2c8800 100644 --- a/cmd/api/src/api/v2/auth/saml.go +++ b/cmd/api/src/api/v2/auth/saml.go @@ -23,6 +23,7 @@ import ( "io" "net/http" "strconv" + "strings" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -144,9 +145,7 @@ func (s ManagementResource) CreateSAMLProviderMultipart(response http.ResponseWr api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) } else if metadata, err := samlsp.ParseMetadata(metadataXML); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) - } else if ssoDescriptor, err := auth.GetIDPSingleSignOnDescriptor(metadata, saml.HTTPPostBinding); err != nil { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) - } else if ssoURL, err := auth.GetIDPSingleSignOnServiceURL(ssoDescriptor, saml.HTTPPostBinding); err != nil { + } else if ssoURL, err := auth.GetIDPSingleSignOnServiceURL(metadata, saml.HTTPPostBinding); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "metadata does not have a SSO service that supports HTTP POST binding", request), response) } else { samlIdentityProvider.Name = providerNames[0] @@ -188,6 +187,74 @@ func (s ManagementResource) DeleteSAMLProvider(response http.ResponseWriter, req } } +// UpdateSAMLProviderRequest updates an SAML provider entry, support for partial payloads +func (s ManagementResource) UpdateSAMLProviderRequest(response http.ResponseWriter, request *http.Request, ssoProvider model.SSOProvider) { + if ssoProvider.SAMLProvider == nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response) + } else if err := request.ParseMultipartForm(api.DefaultAPIPayloadReadLimitBytes); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + } else if providerNames, hasProviderName := request.MultipartForm.Value["name"]; len(providerNames) > 1 { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "expected only one \"name\" parameter", request), response) + } else if metadataXMLFileHandles, hasMetadataXML := request.MultipartForm.File["metadata"]; len(metadataXMLFileHandles) > 1 { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "expected only one \"metadata\" parameter", request), response) + } else { + if hasProviderName { + ssoProvider.Name = providerNames[0] + + ssoProvider.SAMLProvider.Name = providerNames[0] + ssoProvider.SAMLProvider.DisplayName = providerNames[0] + } + + if hasMetadataXML { + if metadataXMLReader, err := metadataXMLFileHandles[0].Open(); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + return + } else { + defer metadataXMLReader.Close() + + if metadataXML, err := io.ReadAll(metadataXMLReader); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + return + } else if metadata, err := samlsp.ParseMetadata(metadataXML); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + return + } else if ssoURL, err := auth.GetIDPSingleSignOnServiceURL(metadata, saml.HTTPPostBinding); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "metadata does not have a SSO service that supports HTTP POST binding", request), response) + return + } else { + ssoProvider.SAMLProvider.MetadataXML = metadataXML + ssoProvider.SAMLProvider.IssuerURI = metadata.EntityID + ssoProvider.SAMLProvider.SingleSignOnURI = ssoURL + + // It's possible to update the ACS url which will be reflected in the metadataXML, we need to guarantee it is set to only what we expect if it is present + if acsUrl, err := auth.GetAssertionConsumerServiceURL(metadata, saml.HTTPPostBinding); err == nil { + if !strings.Contains(acsUrl, model.SAMLRootURIVersionMap[ssoProvider.SAMLProvider.RootURIVersion]) { + var validUri bool + for rootUriVersion, path := range model.SAMLRootURIVersionMap { + if strings.Contains(acsUrl, path) { + ssoProvider.SAMLProvider.RootURIVersion = rootUriVersion + validUri = true + break + } + } + if !validUri { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "metadata does not have a valid ACS location", request), response) + return + } + } + } + } + } + } + + if newSAMLProvider, err := s.db.UpdateSAMLIdentityProvider(request.Context(), ssoProvider); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), newSAMLProvider, http.StatusOK, response) + } + } +} + // Preserve old metadata endpoint func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request *http.Request) { ssoProviderSlug := mux.Vars(request)[api.URIPathVariableSSOProviderSlug] diff --git a/cmd/api/src/api/v2/auth/sso.go b/cmd/api/src/api/v2/auth/sso.go index 027f349e5c..a31efcfe92 100644 --- a/cmd/api/src/api/v2/auth/sso.go +++ b/cmd/api/src/api/v2/auth/sso.go @@ -174,6 +174,27 @@ func (s ManagementResource) DeleteSSOProvider(response http.ResponseWriter, requ } } +// UpdateSSOProvider updates a sso_provider with the matching id +func (s ManagementResource) UpdateSSOProvider(response http.ResponseWriter, request *http.Request) { + rawSSOProviderID := mux.Vars(request)[api.URIPathVariableSSOProviderID] + + // Convert the incoming string url param to an int + if ssoProviderID, err := strconv.Atoi(rawSSOProviderID); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + } else if ssoProvider, err := s.db.GetSSOProviderById(request.Context(), int32(ssoProviderID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + switch ssoProvider.Type { + case model.SessionAuthProviderSAML: + s.UpdateSAMLProviderRequest(response, request, ssoProvider) + case model.SessionAuthProviderOIDC: + s.UpdateOIDCProviderRequest(response, request, ssoProvider) + default: + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotImplemented, api.ErrorResponseDetailsNotImplemented, request), response) + } + } +} + func (s ManagementResource) SSOLoginHandler(response http.ResponseWriter, request *http.Request) { ssoProviderSlug := mux.Vars(request)[api.URIPathVariableSSOProviderSlug] diff --git a/cmd/api/src/auth/saml.go b/cmd/api/src/auth/saml.go index 9d7a529a10..37bbd8fed7 100644 --- a/cmd/api/src/auth/saml.go +++ b/cmd/api/src/auth/saml.go @@ -28,26 +28,42 @@ import ( "github.com/specterops/bloodhound/src/model" ) -func GetIDPSingleSignOnServiceURL(idp saml.IDPSSODescriptor, bindingType string) (string, error) { - for _, singleSignOnService := range idp.SingleSignOnServices { - if singleSignOnService.Binding == bindingType { - return singleSignOnService.Location, nil +func getIDPSingleSignOnDescriptor(metadata *saml.EntityDescriptor, bindingType string) (saml.IDPSSODescriptor, error) { + for _, idpSSODescriptor := range metadata.IDPSSODescriptors { + for _, singleSignOnService := range idpSSODescriptor.SingleSignOnServices { + if singleSignOnService.Binding == bindingType { + return idpSSODescriptor, nil + } } } - return "", fmt.Errorf("no SSO service defined that supports the %s binding type", bindingType) + return saml.IDPSSODescriptor{}, fmt.Errorf("no SSO service defined that supports the %s binding type", bindingType) } -func GetIDPSingleSignOnDescriptor(metadata *saml.EntityDescriptor, bindingType string) (saml.IDPSSODescriptor, error) { - for _, idpSSODescriptor := range metadata.IDPSSODescriptors { - for _, singleSignOnService := range idpSSODescriptor.SingleSignOnServices { +func GetIDPSingleSignOnServiceURL(metadata *saml.EntityDescriptor, bindingType string) (string, error) { + if ssoDescriptor, err := getIDPSingleSignOnDescriptor(metadata, saml.HTTPPostBinding); err != nil { + return "", err + } else { + for _, singleSignOnService := range ssoDescriptor.SingleSignOnServices { if singleSignOnService.Binding == bindingType { - return idpSSODescriptor, nil + return singleSignOnService.Location, nil } } } + return "", fmt.Errorf("no SSO service defined that supports the %s binding type", bindingType) +} - return saml.IDPSSODescriptor{}, fmt.Errorf("no SSO service defined that supports the %s binding type", bindingType) +// GetAssertionConsumerServiceURL This may not be present, we return the first we find +func GetAssertionConsumerServiceURL(metadata *saml.EntityDescriptor, bindingType string) (string, error) { + for _, spSSODescriptor := range metadata.SPSSODescriptors { + for _, acs := range spSSODescriptor.AssertionConsumerServices { + if acs.Binding == bindingType { + return acs.Location, nil + } + } + } + + return "", fmt.Errorf("no SAML ascertion consumer service url defined in metadata xml") } func NewServiceProvider(hostUrl url.URL, cfg config.Configuration, samlProvider model.SAMLProvider) (saml.ServiceProvider, error) { diff --git a/cmd/api/src/database/auth_test.go b/cmd/api/src/database/auth_test.go index 8ec2bcfeca..ec586c25e0 100644 --- a/cmd/api/src/database/auth_test.go +++ b/cmd/api/src/database/auth_test.go @@ -300,13 +300,12 @@ func TestDatabase_CreateGetDeleteAuthSecret(t *testing.T) { func TestDatabase_CreateUpdateDeleteSAMLProvider(t *testing.T) { var ( - ctx = context.Background() - dbInst, user = initAndCreateUser(t) - samlProvider model.SAMLProvider - newSAMLProvider model.SAMLProvider - updatedUser model.User - updatedSAMLProvider model.SAMLProvider - err error + ctx = context.Background() + dbInst, user = initAndCreateUser(t) + samlProvider model.SAMLProvider + newSAMLProvider model.SAMLProvider + updatedUser model.User + err error ) // Initialize the SAMLProvider without setting SSOProviderID samlProvider = model.SAMLProvider{ @@ -333,18 +332,22 @@ func TestDatabase_CreateUpdateDeleteSAMLProvider(t *testing.T) { } else if updatedUser.SSOProvider.SAMLProvider.IssuerURI != newSAMLProvider.IssuerURI { t.Fatalf("Updated user has SAMLProvider URL %s when %s was expected", updatedUser.SSOProvider.SAMLProvider.IssuerURI, newSAMLProvider.IssuerURI) } else { - updatedSAMLProvider = model.SAMLProvider{ - Serial: model.Serial{ - ID: newSAMLProvider.ID, + updatedSSOProvider := model.SSOProvider{ + Name: "updated provider", + Type: model.SessionAuthProviderSAML, + SAMLProvider: &model.SAMLProvider{ + Serial: model.Serial{ + ID: newSAMLProvider.ID, + }, + Name: "updated provider", + DisplayName: newSAMLProvider.DisplayName, + IssuerURI: newSAMLProvider.IssuerURI, + SingleSignOnURI: newSAMLProvider.SingleSignOnURI, + SSOProviderID: newSAMLProvider.SSOProviderID, }, - Name: "updated provider", - DisplayName: newSAMLProvider.DisplayName, - IssuerURI: newSAMLProvider.IssuerURI, - SingleSignOnURI: newSAMLProvider.SingleSignOnURI, - SSOProviderID: newSAMLProvider.SSOProviderID, } - if err = dbInst.UpdateSAMLIdentityProvider(ctx, updatedSAMLProvider); err != nil { + if _, err = dbInst.UpdateSAMLIdentityProvider(ctx, updatedSSOProvider); err != nil { t.Fatalf("Failed to update SAML provider: %v", err) } else if err = test.VerifyAuditLogs(dbInst, model.AuditLogActionUpdateSAMLIdentityProvider, "saml_name", "updated provider"); err != nil { t.Fatalf("Failed to validate UpdateSAMLIdentityProvider audit logs:\n%v", err) diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 271430e0dc..2482616a01 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -1593,6 +1593,20 @@ func (mr *MockDatabaseMockRecorder) SweepSessions(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SweepSessions", reflect.TypeOf((*MockDatabase)(nil).SweepSessions), arg0) } +// TerminateUserSessionsBySSOProvider mocks base method. +func (m *MockDatabase) TerminateUserSessionsBySSOProvider(arg0 context.Context, arg1 model.SSOProvider) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TerminateUserSessionsBySSOProvider", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// TerminateUserSessionsBySSOProvider indicates an expected call of TerminateUserSessionsBySSOProvider. +func (mr *MockDatabaseMockRecorder) TerminateUserSessionsBySSOProvider(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TerminateUserSessionsBySSOProvider", reflect.TypeOf((*MockDatabase)(nil).TerminateUserSessionsBySSOProvider), arg0, arg1) +} + // UpdateAssetGroup mocks base method. func (m *MockDatabase) UpdateAssetGroup(arg0 context.Context, arg1 model.AssetGroup) error { m.ctrl.T.Helper() @@ -1664,12 +1678,28 @@ func (mr *MockDatabaseMockRecorder) UpdateFileUploadJob(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFileUploadJob", reflect.TypeOf((*MockDatabase)(nil).UpdateFileUploadJob), arg0, arg1) } +// UpdateOIDCProvider mocks base method. +func (m *MockDatabase) UpdateOIDCProvider(arg0 context.Context, arg1 model.SSOProvider) (model.OIDCProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOIDCProvider", arg0, arg1) + ret0, _ := ret[0].(model.OIDCProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateOIDCProvider indicates an expected call of UpdateOIDCProvider. +func (mr *MockDatabaseMockRecorder) UpdateOIDCProvider(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOIDCProvider", reflect.TypeOf((*MockDatabase)(nil).UpdateOIDCProvider), arg0, arg1) +} + // UpdateSAMLIdentityProvider mocks base method. -func (m *MockDatabase) UpdateSAMLIdentityProvider(arg0 context.Context, arg1 model.SAMLProvider) error { +func (m *MockDatabase) UpdateSAMLIdentityProvider(arg0 context.Context, arg1 model.SSOProvider) (model.SAMLProvider, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateSAMLIdentityProvider", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(model.SAMLProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateSAMLIdentityProvider indicates an expected call of UpdateSAMLIdentityProvider. @@ -1678,6 +1708,21 @@ func (mr *MockDatabaseMockRecorder) UpdateSAMLIdentityProvider(arg0, arg1 interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSAMLIdentityProvider", reflect.TypeOf((*MockDatabase)(nil).UpdateSAMLIdentityProvider), arg0, arg1) } +// UpdateSSOProvider mocks base method. +func (m *MockDatabase) UpdateSSOProvider(arg0 context.Context, arg1 model.SSOProvider) (model.SSOProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateSSOProvider", arg0, arg1) + ret0, _ := ret[0].(model.SSOProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateSSOProvider indicates an expected call of UpdateSSOProvider. +func (mr *MockDatabaseMockRecorder) UpdateSSOProvider(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSSOProvider", reflect.TypeOf((*MockDatabase)(nil).UpdateSSOProvider), arg0, arg1) +} + // UpdateSavedQuery mocks base method. func (m *MockDatabase) UpdateSavedQuery(arg0 context.Context, arg1 model.SavedQuery) (model.SavedQuery, error) { m.ctrl.T.Helper() diff --git a/cmd/api/src/database/oidc_providers.go b/cmd/api/src/database/oidc_providers.go index f66066ba47..fdc9c0b911 100644 --- a/cmd/api/src/database/oidc_providers.go +++ b/cmd/api/src/database/oidc_providers.go @@ -18,6 +18,8 @@ package database import ( "context" + "fmt" + "time" "github.com/specterops/bloodhound/src/model" "gorm.io/gorm" @@ -30,6 +32,7 @@ const ( // OIDCProviderData defines the interface required to interact with the oidc_providers table type OIDCProviderData interface { CreateOIDCProvider(ctx context.Context, name, issuer, clientID string) (model.OIDCProvider, error) + UpdateOIDCProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.OIDCProvider, error) } // CreateOIDCProvider creates a new entry for an OIDC provider as well as the associated SSO provider @@ -61,3 +64,29 @@ func (s *BloodhoundDB) CreateOIDCProvider(ctx context.Context, name, issuer, cli return oidcProvider, err } + +// UpdateOIDCProvider updates an OIDC provider as well as the associated SSO provider +func (s *BloodhoundDB) UpdateOIDCProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.OIDCProvider, error) { + auditEntry := model.AuditEntry{ + Action: model.AuditLogActionUpdateOIDCIdentityProvider, + Model: ssoProvider.OIDCProvider, // Pointer is required to ensure success log contains updated fields after transaction + } + + // update both the sso_providers, oidc_providers, and user_sessions rows in a single transaction + // If one of these requests errors, all changes will be rolled back + err := s.AuditableTransaction(ctx, auditEntry, func(tx *gorm.DB) error { + bhdb := NewBloodhoundDB(tx, s.idResolver) + + if _, err := bhdb.UpdateSSOProvider(ctx, ssoProvider); err != nil { + return err + } else if err := CheckError(tx.WithContext(ctx).Exec(fmt.Sprintf("UPDATE %s SET client_id = ?, issuer = ?, updated_at = ? WHERE id = ?;", oidcProvidersTableName), + ssoProvider.OIDCProvider.ClientID, ssoProvider.OIDCProvider.Issuer, time.Now().UTC(), ssoProvider.OIDCProvider.ID)); err != nil { + return err + } else { + // Ensure all existing sessions are invalidated within the tx + return bhdb.TerminateUserSessionsBySSOProvider(ctx, ssoProvider) + } + }) + + return *ssoProvider.OIDCProvider, err +} diff --git a/cmd/api/src/database/oidc_providers_test.go b/cmd/api/src/database/oidc_providers_test.go index 28b6d716aa..dfad1db7a8 100644 --- a/cmd/api/src/database/oidc_providers_test.go +++ b/cmd/api/src/database/oidc_providers_test.go @@ -26,27 +26,50 @@ import ( "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/test/integration" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestBloodhoundDB_CreateOIDCProvider(t *testing.T) { +func TestBloodhoundDB_CreateUpdateOIDCProvider(t *testing.T) { var ( testCtx = context.Background() dbInst = integration.SetupDB(t) ) defer dbInst.Close(testCtx) - t.Run("successfully create an OIDC provider", func(t *testing.T) { + t.Run("successfully create and update an OIDC provider", func(t *testing.T) { provider, err := dbInst.CreateOIDCProvider(testCtx, "test", "https://test.localhost.com/auth", "bloodhound") require.NoError(t, err) - assert.Equal(t, "https://test.localhost.com/auth", provider.Issuer) - assert.Equal(t, "bloodhound", provider.ClientID) - assert.NotEmpty(t, provider.ID) + require.Equal(t, "https://test.localhost.com/auth", provider.Issuer) + require.Equal(t, "bloodhound", provider.ClientID) + require.EqualValues(t, 1, provider.ID) - _, count, err := dbInst.ListAuditLogs(testCtx, time.Now().Add(-time.Minute), time.Now().Add(time.Minute), 0, 10, "", model.SQLFilter{}) + _, count, err := dbInst.ListAuditLogs(testCtx, time.Now().Add(time.Minute), time.Now().Add(-time.Minute), 0, 10, "", model.SQLFilter{}) require.NoError(t, err) - assert.Equal(t, 4, count) + require.Equal(t, 4, count) + + updatedSSOProvider := model.SSOProvider{ + Name: "updated provider", + Type: model.SessionAuthProviderOIDC, + OIDCProvider: &model.OIDCProvider{ + Serial: model.Serial{ + ID: provider.ID, + }, + ClientID: "gotham-net", + Issuer: "https://gotham.net", + SSOProviderID: provider.SSOProviderID, + }, + } + + provider, err = dbInst.UpdateOIDCProvider(testCtx, updatedSSOProvider) + require.NoError(t, err) + + require.Equal(t, updatedSSOProvider.OIDCProvider.Issuer, provider.Issuer) + require.Equal(t, updatedSSOProvider.OIDCProvider.ClientID, provider.ClientID) + require.EqualValues(t, updatedSSOProvider.OIDCProvider.ID, provider.ID) + + _, count, err = dbInst.ListAuditLogs(testCtx, time.Now().Add(time.Minute), time.Now().Add(-time.Minute), 0, 10, "", model.SQLFilter{}) + require.NoError(t, err) + require.Equal(t, 8, count) }) } diff --git a/cmd/api/src/database/samlproviders.go b/cmd/api/src/database/samlproviders.go index ce822387ab..73df1197a5 100644 --- a/cmd/api/src/database/samlproviders.go +++ b/cmd/api/src/database/samlproviders.go @@ -18,6 +18,8 @@ package database import ( "context" + "fmt" + "time" "github.com/specterops/bloodhound/src/database/types/null" "github.com/specterops/bloodhound/src/model" @@ -34,7 +36,7 @@ type SAMLProviderData interface { GetAllSAMLProviders(ctx context.Context) (model.SAMLProviders, error) GetSAMLProvider(ctx context.Context, id int32) (model.SAMLProvider, error) GetSAMLProviderUsers(ctx context.Context, id int32) (model.Users, error) - UpdateSAMLIdentityProvider(ctx context.Context, samlProvider model.SAMLProvider) error + UpdateSAMLIdentityProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.SAMLProvider, error) } // CreateSAMLIdentityProvider creates a new saml_providers row using the data in the input struct @@ -95,13 +97,27 @@ func (s *BloodhoundDB) GetSAMLProviderUsers(ctx context.Context, id int32) (mode // CreateSAMLProvider updates a saml_providers row using the data in the input struct // UPDATE saml_identity_providers SET (...) VALUES (...) WHERE id = ... -func (s *BloodhoundDB) UpdateSAMLIdentityProvider(ctx context.Context, provider model.SAMLProvider) error { +func (s *BloodhoundDB) UpdateSAMLIdentityProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.SAMLProvider, error) { auditEntry := model.AuditEntry{ Action: model.AuditLogActionUpdateSAMLIdentityProvider, - Model: &provider, // Pointer is required to ensure success log contains updated fields after transaction + Model: ssoProvider.SAMLProvider, // Pointer is required to ensure success log contains updated fields after transaction } - return s.AuditableTransaction(ctx, auditEntry, func(tx *gorm.DB) error { - return CheckError(tx.WithContext(ctx).Save(&provider)) + err := s.AuditableTransaction(ctx, auditEntry, func(tx *gorm.DB) error { + bhdb := NewBloodhoundDB(tx, s.idResolver) + + if _, err := bhdb.UpdateSSOProvider(ctx, ssoProvider); err != nil { + return err + } else if err := CheckError(tx.WithContext(ctx).Exec( + fmt.Sprintf("UPDATE %s SET name = ?, display_name = ?, issuer_uri = ?, single_sign_on_uri = ?, metadata_xml = ?, updated_at = ? WHERE id = ?;", samlProvidersTableName), + ssoProvider.SAMLProvider.Name, ssoProvider.SAMLProvider.DisplayName, ssoProvider.SAMLProvider.IssuerURI, ssoProvider.SAMLProvider.SingleSignOnURI, ssoProvider.SAMLProvider.MetadataXML, time.Now().UTC(), ssoProvider.SAMLProvider.ID), + ); err != nil { + return err + } else { + // Ensure all existing sessions are invalidated within the tx + return bhdb.TerminateUserSessionsBySSOProvider(ctx, ssoProvider) + } }) + + return *ssoProvider.SAMLProvider, err } diff --git a/cmd/api/src/database/sso_providers.go b/cmd/api/src/database/sso_providers.go index fd1f710d60..9aa22375ce 100644 --- a/cmd/api/src/database/sso_providers.go +++ b/cmd/api/src/database/sso_providers.go @@ -18,7 +18,9 @@ package database import ( "context" + "fmt" "strings" + "time" "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/model/appcfg" @@ -37,6 +39,8 @@ type SSOProviderData interface { GetSSOProviderById(ctx context.Context, id int32) (model.SSOProvider, error) GetSSOProviderBySlug(ctx context.Context, slug string) (model.SSOProvider, error) GetSSOProviderUsers(ctx context.Context, id int) (model.Users, error) + TerminateUserSessionsBySSOProvider(ctx context.Context, ssoProvider model.SSOProvider) error + UpdateSSOProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.SSOProvider, error) } // CreateSSOProvider creates an entry in the sso_providers table @@ -145,3 +149,42 @@ func (s *BloodhoundDB) GetSSOProviderById(ctx context.Context, id int32) (model. return provider, CheckError(result) } + +// TerminateUserSessionsBySSOProvider terminates all sessions associated with a specific sso provider +func (s *BloodhoundDB) TerminateUserSessionsBySSOProvider(ctx context.Context, ssoProvider model.SSOProvider) error { + // TODO should be migrated to the SSO provider id instead of the child + var childId int32 + switch ssoProvider.Type { + case model.SessionAuthProviderSAML: + if ssoProvider.SAMLProvider != nil { + childId = ssoProvider.SAMLProvider.ID + } + case model.SessionAuthProviderOIDC: + if ssoProvider.OIDCProvider != nil { + childId = ssoProvider.OIDCProvider.ID + } + } + + if childId == 0 { + return ErrNotFound + } + + return CheckError(s.db.WithContext(ctx).Table("user_sessions").Where("auth_provider_type = ? AND auth_provider_id = ?", ssoProvider.Type, childId).Update("expires_at", gorm.Expr("NOW()"))) +} + +// UpdateSSOProvider updates an entry in the sso_providers table +func (s *BloodhoundDB) UpdateSSOProvider(ctx context.Context, ssoProvider model.SSOProvider) (model.SSOProvider, error) { + // Update the slug + ssoProvider.Slug = strings.ToLower(strings.ReplaceAll(ssoProvider.Name, " ", "-")) + + auditEntry := model.AuditEntry{ + Action: model.AuditLogActionUpdateSSOIdentityProvider, + Model: &ssoProvider, + } + + err := s.AuditableTransaction(ctx, auditEntry, func(tx *gorm.DB) error { + return CheckError(tx.WithContext(ctx).Exec(fmt.Sprintf("UPDATE %s SET name = ?, slug = ?, updated_at = ? WHERE id = ?;", ssoProviderTableName), ssoProvider.Name, ssoProvider.Slug, time.Now().UTC(), ssoProvider.ID)) + }) + + return ssoProvider, err +} diff --git a/cmd/api/src/model/audit.go b/cmd/api/src/model/audit.go index edde6ece6c..c5f5d68f10 100644 --- a/cmd/api/src/model/audit.go +++ b/cmd/api/src/model/audit.go @@ -64,8 +64,10 @@ const ( AuditLogActionUpdateSAMLIdentityProvider AuditLogAction = "UpdateSAMLIdentityProvider" AuditLogActionCreateOIDCIdentityProvider AuditLogAction = "CreateOIDCIdentityProvider" + AuditLogActionUpdateOIDCIdentityProvider AuditLogAction = "UpdateOIDCIdentityProvider" AuditLogActionCreateSSOIdentityProvider AuditLogAction = "CreateSSOIdentityProvider" + AuditLogActionUpdateSSOIdentityProvider AuditLogAction = "UpdateSSOIdentityProvider" AuditLogActionDeleteSSOIdentityProvider AuditLogAction = "DeleteSSOIdentityProvider" AuditLogActionAcceptRisk AuditLogAction = "AcceptRisk" diff --git a/cmd/api/src/model/samlprovider.go b/cmd/api/src/model/samlprovider.go index 124039c2a6..480e9b002f 100644 --- a/cmd/api/src/model/samlprovider.go +++ b/cmd/api/src/model/samlprovider.go @@ -43,8 +43,13 @@ var ( type SAMLRootURIVersion int var ( - SAMLRootURIVersion1 SAMLRootURIVersion = 1 // "/v2/login/saml/{slug}/" - SAMLRootURIVersion2 SAMLRootURIVersion = 2 // "/v2/sso/{slug}/" + SAMLRootURIVersion1 SAMLRootURIVersion = 1 + SAMLRootURIVersion2 SAMLRootURIVersion = 2 + + SAMLRootURIVersionMap = map[SAMLRootURIVersion]string { + SAMLRootURIVersion1: "/api/v1/login/saml", + SAMLRootURIVersion2: "/api/v2/sso", + } ) type SAMLProvider struct { @@ -142,14 +147,13 @@ func (s SAMLProvider) GetSAMLUserPrincipalNameFromAssertion(assertion *saml.Asse func (s *SAMLProvider) FormatSAMLProviderURLs(hostUrl url.URL) { root := hostUrl + root.Path = path.Join(SAMLRootURIVersionMap[s.RootURIVersion], s.Name) // To preserve existing IDP configurations, existing saml providers still use the old acs endpoint which redirects to the new callback handler switch s.RootURIVersion { case SAMLRootURIVersion1: - root.Path = path.Join("/api/v1/login/saml", s.Name) s.ServiceProviderACSURI = serde.FromURL(*root.JoinPath("acs")) case SAMLRootURIVersion2: - root.Path = path.Join("/api/v2/sso", s.Name) s.ServiceProviderACSURI = serde.FromURL(*root.JoinPath("callback")) } diff --git a/cmd/api/src/utils/validation/url_validator.go b/cmd/api/src/utils/validation/url_validator.go index 1a802d5cd8..d5eb1236f8 100644 --- a/cmd/api/src/utils/validation/url_validator.go +++ b/cmd/api/src/utils/validation/url_validator.go @@ -66,7 +66,7 @@ func (s UrlValidator) Validate(value any) utils.Errors { } } - if err := validUrl(inputUrl); err != nil { + if err := ValidUrl(inputUrl); err != nil { errs = append(errs, errors.New(ErrorUrlInvalid)) } @@ -77,7 +77,7 @@ func (s UrlValidator) Validate(value any) utils.Errors { return nil } -func validUrl(inputUrl string) error { +func ValidUrl(inputUrl string) error { if _, err := url.ParseRequestURI(inputUrl); err != nil { return err } From 40a67b17201b610ee60ab5f5a776648c0353ce60 Mon Sep 17 00:00:00 2001 From: Stephen Hinck Date: Wed, 27 Nov 2024 07:01:17 -0800 Subject: [PATCH 09/18] BED-5078: Fix bug in Kerberoastable users with most privs (#983) * BED-5078: Fix bug in Kerberoastable users with most privs * Update for query feedback --- packages/go/graphschema/ad/ad.go | 1 - packages/go/graphschema/azure/azure.go | 1 - packages/go/graphschema/common/common.go | 1 - packages/javascript/bh-shared-ui/src/commonSearches.tsx | 4 ++-- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/go/graphschema/ad/ad.go b/packages/go/graphschema/ad/ad.go index 5dad6bea8d..4a948558ff 100644 --- a/packages/go/graphschema/ad/ad.go +++ b/packages/go/graphschema/ad/ad.go @@ -21,7 +21,6 @@ package ad import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go index 787ee392e6..00b20f190f 100644 --- a/packages/go/graphschema/azure/azure.go +++ b/packages/go/graphschema/azure/azure.go @@ -21,7 +21,6 @@ package azure import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/common/common.go b/packages/go/graphschema/common/common.go index 73edf123fa..631871c6bf 100644 --- a/packages/go/graphschema/common/common.go +++ b/packages/go/graphschema/common/common.go @@ -21,7 +21,6 @@ package common import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/javascript/bh-shared-ui/src/commonSearches.tsx b/packages/javascript/bh-shared-ui/src/commonSearches.tsx index 6a16f8a309..cc4fae7a38 100644 --- a/packages/javascript/bh-shared-ui/src/commonSearches.tsx +++ b/packages/javascript/bh-shared-ui/src/commonSearches.tsx @@ -112,8 +112,8 @@ export const CommonSearches: CommonSearchType[] = [ cypher: `MATCH (u:User)\nWHERE u.hasspn=true\nAND u.enabled = true\nAND NOT u.objectid ENDS WITH '-502'\nAND NOT coalesce(u.gmsa, ' ') = true\nAND NOT coalesce(u.msa, ' ') = true\nRETURN u\nLIMIT 100`, }, { - description: 'Kerberoastable users with most privileges', - cypher: `MATCH (u:User)\nOPTIONAL MATCH (u)-[:AdminTo]->(c1:Computer)\nOPTIONAL MATCH (u)-[:MemberOf*1..]->(:Group)-[:AdminTo]->(c2:Computer)\nWHERE u.hasspn=true\nAND u.enabled = true\nAND NOT u.objectid ENDS WITH '-502'\nAND NOT coalesce(u.gmsa, ' ') = true\nAND NOT coalesce(u.msa, ' ') = true\nWITH u,COLLECT(c1) + COLLECT(c2) AS tempVar\nUNWIND tempVar AS comps\nRETURN u\nLIMIT 100`, + description: 'Kerberoastable users with most admin privileges', + cypher: `MATCH (u:User)\nWHERE u.hasspn = true\n AND u.enabled = true\n AND NOT u.objectid ENDS WITH '-502'\n AND NOT coalesce(u.gmsa, ' ') = true\n AND NOT coalesce(u.msa, ' ') = true\nMATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer)\nWITH DISTINCT u, COUNT(c) AS adminCount\nRETURN u\nORDER BY adminCount DESC\nLIMIT 100`, }, { description: 'AS-REP Roastable users (DontReqPreAuth)', From 5d4b9b50211b8d93291b31ad5b294aa982cf7c5d Mon Sep 17 00:00:00 2001 From: John Hopper Date: Tue, 26 Nov 2024 13:43:28 -0800 Subject: [PATCH 10/18] chore: BED-4954 - provide formal integration test to cover ticket --- packages/go/analysis/hybrid/hybrid.go | 12 ++- packages/go/analysis/post_integration_test.go | 98 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/go/analysis/post_integration_test.go diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go index 9e830dd322..382abe3c2c 100644 --- a/packages/go/analysis/hybrid/hybrid.go +++ b/packages/go/analysis/hybrid/hybrid.go @@ -34,11 +34,13 @@ import ( ) func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { + // Fetch all Azure tenants first tenants, err := azure.FetchTenants(ctx, db) if err != nil { return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("fetching Entra tenants: %w", err) } + // Spin up a new parallel operation to speed up processing operation := analysis.NewPostRelationshipOperation(ctx, db, "Hybrid Attack Paths Post Processing") err = db.ReadTransaction(ctx, func(tx graph.Transaction) error { @@ -51,14 +53,18 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro entraToADMap = make(map[graph.ID]graph.ID, 1024) ) - // Work on Entra users by their tenant association + // Work on Entra users by their tenant association. Loop therefore through each Entra tenant for _, tenant := range tenants { + // Fetch all users in this Entra tenant if tenantUsers, err := fetchEntraUsers(tx, tenant); err != nil { return err } else if len(tenantUsers) == 0 { + // If there are no users present, exit this loop continue } else { + // Loop through each Entra user in this tenant for _, tenantUser := range tenantUsers { + // Check to see if the Entra user has an on prem sync property set if onPremID, hasOnPrem, err := hasOnPremUser(tenantUser); !hasOnPrem { continue } else if err != nil { @@ -67,6 +73,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro // We know this user has an onPrem counterpart, so add the node id and onPremID to our three maps adObjIDMap[onPremID] = append(adObjIDMap[onPremID], tenantUser.ID) entraObjIDMap[tenantUser.ID] = onPremID + // Initialize the current user id as an index in the entraToADMap, but use 0 as the nodeid for AD since we // currently don't know it and 0 is never going to be a valid user node id entraToADMap[tenantUser.ID] = 0 @@ -80,10 +87,13 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro if adUsers, err := fetchADUsers(tx); err != nil { return err } else { + // Loop through each Active Directory user for _, adUser := range adUsers { + // Get the user's Object ID if objectID, err := adUser.Properties.Get(common.ObjectID.String()).String(); err != nil { return err } else if azUsers, ok := adObjIDMap[objectID]; !ok { + // Skip adding this relationship if we've already seen it before as that implies it will be created continue } else { // Because there could theoretically be more than one Entra user mapped to this objectid, we want to loop through all when adding our current id to the final map diff --git a/packages/go/analysis/post_integration_test.go b/packages/go/analysis/post_integration_test.go new file mode 100644 index 0000000000..cf8cdea82c --- /dev/null +++ b/packages/go/analysis/post_integration_test.go @@ -0,0 +1,98 @@ +//go:build serial_integration +// +build serial_integration + +package analysis_test + +import ( + "context" + "github.com/specterops/bloodhound/analysis" + adAnalysis "github.com/specterops/bloodhound/analysis/ad" + azureAnalysis "github.com/specterops/bloodhound/analysis/azure" + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/specterops/bloodhound/dawgs/query" + "github.com/specterops/bloodhound/graphschema" + "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/graphschema/azure" + "github.com/specterops/bloodhound/src/test/integration" + "github.com/stretchr/testify/require" + "testing" +) + +// This is a test to validate when we have a situ such +// There exists an AD user and an Azure user that represent the same principal (the same user identity) +// +// This connection is made by correlating properties that are inserted when data from Active Directory or Azure is ingested +// into the system. These properties are referenced in the function in bhce/packages/go/analysis/hybrid/hybrid.go - hasOnPremUser(...) +// and then mapped to AD users for creation of the SyncedToEntraUser and SyncedToADUser edges. +// +// Hybrid post-processing is driven by https://learn.microsoft.com/en-us/azure/architecture/reference-architectures/identity/azure-ad - current +// limitations of the implementation in MS means that the relationship between User and AZUser is 1:* where a AZUser may only be connected to +// one AD principal. +func TestDeleteTransitEdges(t *testing.T) { + var ( + // This creates a new live integration test context with the graph database + // This call will load whatever BHE configuration the environment variable `INTEGRATION_CONFIG_PATH` points to. + textCtx = integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) + + // For this test we need to validate BED-4954 - this requires, at minimum, an AD user and an Entra (Azure) user. The lines below + // will utilize the test context to put the data directly into the graph. + + // AD user first + adUser = textCtx.NewNode(graph.AsProperties(map[string]any{ + "name": "ad_user", + "objectid": "1234", + }), ad.Entity, ad.User) + + // Azure user second + azureUser = textCtx.NewNode(graph.AsProperties(map[string]any{ + "name": "azure_user", + "objectid": "4321", + }), azure.Entity, azure.User) + ) + + // In order to validate that DeleteTransitEdges and the updated PostProcessedRelationships for both AD and Azure are correct, we need to simulate + // the completion of post-processing in: lib/go/analysis/azure/post.go + // + // The specific function that is responsible for creating the edges below can be found in bhce/packages/go/analysis/hybrid/hybrid.go - PostHybrid(...) + // + // Here, we are choosing to create these edges such that the data describes what we would expect to see after a successful execution of the logic + // in lib/go/analysis/azure/post.go. + textCtx.NewRelationship(adUser, azureUser, ad.SyncedToEntraUser) + textCtx.NewRelationship(azureUser, adUser, azure.SyncedToADUser) + + // The way post-processing operates is that all edges created during post-processing are deleted before each analysis run. This helps keep the graph consistent + // where certain graph conditions (edges, node properties, etc.) that once existed were removed or modified due to the user's environment changing. + + // This first run removes all Azure post-processed relationships - expected outcome is that SyncedToADUser is removed at this stage + _, err := analysis.DeleteTransitEdges(context.Background(), textCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, azureAnalysis.PostProcessedRelationships()...) + + // Deleting transit edges must not return an error + require.Nil(t, err) + + err = textCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + numEdges, err := tx.Relationships().Filter(query.Kind(query.Relationship(), azure.SyncedToADUser)).Count() + + // This must be true which would mean that the above created SyncedToADUser was correctly deleted by the DeleteTransitEdges call + require.Equal(t, int64(0), numEdges) + return err + }) + + // The DB must not return any errors + require.Nil(t, err) + + // This first run removes all AD post-processed relationships - expected outcome is that SyncedToEntraUser is removed at this stage + _, err = analysis.DeleteTransitEdges(context.Background(), textCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, adAnalysis.PostProcessedRelationships()...) + // Deleting transit edges must not return an error + require.Nil(t, err) + + err = textCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + numEdges, err := tx.Relationships().Filter(query.Kind(query.Relationship(), ad.SyncedToEntraUser)).Count() + + // This must be true which would mean that the above created SyncedToADUser was correctly deleted by the DeleteTransitEdges call + require.Equal(t, int64(0), numEdges) + return err + }) + + // The DB must not return any errors + require.Nil(t, err) +} From 1156c0ca896b22b871cc955cea31d6fcbec8c185 Mon Sep 17 00:00:00 2001 From: Jigyasa Date: Wed, 27 Nov 2024 12:04:42 -0600 Subject: [PATCH 11/18] Chore: Updating variable name to testCtx --- packages/go/analysis/post_integration_test.go | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/go/analysis/post_integration_test.go b/packages/go/analysis/post_integration_test.go index cf8cdea82c..a82d81be37 100644 --- a/packages/go/analysis/post_integration_test.go +++ b/packages/go/analysis/post_integration_test.go @@ -5,6 +5,8 @@ package analysis_test import ( "context" + "testing" + "github.com/specterops/bloodhound/analysis" adAnalysis "github.com/specterops/bloodhound/analysis/ad" azureAnalysis "github.com/specterops/bloodhound/analysis/azure" @@ -15,7 +17,6 @@ import ( "github.com/specterops/bloodhound/graphschema/azure" "github.com/specterops/bloodhound/src/test/integration" "github.com/stretchr/testify/require" - "testing" ) // This is a test to validate when we have a situ such @@ -32,19 +33,19 @@ func TestDeleteTransitEdges(t *testing.T) { var ( // This creates a new live integration test context with the graph database // This call will load whatever BHE configuration the environment variable `INTEGRATION_CONFIG_PATH` points to. - textCtx = integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) + testCtx = integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) // For this test we need to validate BED-4954 - this requires, at minimum, an AD user and an Entra (Azure) user. The lines below // will utilize the test context to put the data directly into the graph. // AD user first - adUser = textCtx.NewNode(graph.AsProperties(map[string]any{ + adUser = testCtx.NewNode(graph.AsProperties(map[string]any{ "name": "ad_user", "objectid": "1234", }), ad.Entity, ad.User) // Azure user second - azureUser = textCtx.NewNode(graph.AsProperties(map[string]any{ + azureUser = testCtx.NewNode(graph.AsProperties(map[string]any{ "name": "azure_user", "objectid": "4321", }), azure.Entity, azure.User) @@ -57,19 +58,19 @@ func TestDeleteTransitEdges(t *testing.T) { // // Here, we are choosing to create these edges such that the data describes what we would expect to see after a successful execution of the logic // in lib/go/analysis/azure/post.go. - textCtx.NewRelationship(adUser, azureUser, ad.SyncedToEntraUser) - textCtx.NewRelationship(azureUser, adUser, azure.SyncedToADUser) + testCtx.NewRelationship(adUser, azureUser, ad.SyncedToEntraUser) + testCtx.NewRelationship(azureUser, adUser, azure.SyncedToADUser) // The way post-processing operates is that all edges created during post-processing are deleted before each analysis run. This helps keep the graph consistent // where certain graph conditions (edges, node properties, etc.) that once existed were removed or modified due to the user's environment changing. // This first run removes all Azure post-processed relationships - expected outcome is that SyncedToADUser is removed at this stage - _, err := analysis.DeleteTransitEdges(context.Background(), textCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, azureAnalysis.PostProcessedRelationships()...) + _, err := analysis.DeleteTransitEdges(context.Background(), testCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, azureAnalysis.PostProcessedRelationships()...) // Deleting transit edges must not return an error require.Nil(t, err) - err = textCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + err = testCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { numEdges, err := tx.Relationships().Filter(query.Kind(query.Relationship(), azure.SyncedToADUser)).Count() // This must be true which would mean that the above created SyncedToADUser was correctly deleted by the DeleteTransitEdges call @@ -81,11 +82,11 @@ func TestDeleteTransitEdges(t *testing.T) { require.Nil(t, err) // This first run removes all AD post-processed relationships - expected outcome is that SyncedToEntraUser is removed at this stage - _, err = analysis.DeleteTransitEdges(context.Background(), textCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, adAnalysis.PostProcessedRelationships()...) + _, err = analysis.DeleteTransitEdges(context.Background(), testCtx.Graph.Database, graph.Kinds{ad.Entity, azure.Entity}, adAnalysis.PostProcessedRelationships()...) // Deleting transit edges must not return an error require.Nil(t, err) - err = textCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + err = testCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error { numEdges, err := tx.Relationships().Filter(query.Kind(query.Relationship(), ad.SyncedToEntraUser)).Count() // This must be true which would mean that the above created SyncedToADUser was correctly deleted by the DeleteTransitEdges call From cf854b2cc799003608b585049c776ca98eb01cfa Mon Sep 17 00:00:00 2001 From: Wesley Maffly-Kipp Date: Wed, 27 Nov 2024 11:03:27 -0800 Subject: [PATCH 12/18] BED-4817: Update API types for posture page (#973) * update posture-history request url * updated api contract for posture-history * updated doodle version * updated posture history url * correctly pass query params to API request * fix key issue in test --- ...-1.0.0-alpha.12-81cbc222a8-df3f49e535.zip} | Bin 154592 -> 154624 bytes cmd/ui/package.json | 2 +- packages/javascript/bh-shared-ui/package.json | 2 +- .../SSOProviderTable.test.tsx | 2 +- .../js-client-library/src/client.ts | 18 ++++++++++-------- .../js-client-library/src/responses.ts | 11 +++++------ yarn.lock | 12 ++++++------ 7 files changed, 24 insertions(+), 23 deletions(-) rename .yarn/cache/{@bloodhoundenterprise-doodleui-npm-1.0.0-alpha.11-d1ae1eb885-38acdc0182.zip => @bloodhoundenterprise-doodleui-npm-1.0.0-alpha.12-81cbc222a8-df3f49e535.zip} (75%) diff --git a/.yarn/cache/@bloodhoundenterprise-doodleui-npm-1.0.0-alpha.11-d1ae1eb885-38acdc0182.zip b/.yarn/cache/@bloodhoundenterprise-doodleui-npm-1.0.0-alpha.12-81cbc222a8-df3f49e535.zip similarity index 75% rename from .yarn/cache/@bloodhoundenterprise-doodleui-npm-1.0.0-alpha.11-d1ae1eb885-38acdc0182.zip rename to .yarn/cache/@bloodhoundenterprise-doodleui-npm-1.0.0-alpha.12-81cbc222a8-df3f49e535.zip index f82ed9a9d92dbd207344e259e8ea1983beba014b..b8f123e0c57a290b35faf2f6bc5a14c23bea39bc 100644 GIT binary patch delta 36873 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 Z0y6_LNCK1L5Hgp*TLKye&Q}5e007Pi<&gjY delta 36840 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 diff --git a/cmd/ui/package.json b/cmd/ui/package.json index 02502b3549..5115ad5e34 100644 --- a/cmd/ui/package.json +++ b/cmd/ui/package.json @@ -16,7 +16,7 @@ "check-format": "prettier --list-different \"src/**/*.@(js|jsx|ts|tsx|md|html|css|scss|json)\"" }, "dependencies": { - "@bloodhoundenterprise/doodleui": "^1.0.0-alpha.11", + "@bloodhoundenterprise/doodleui": "^1.0.0-alpha.12", "@date-io/luxon": "^1.3.13", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", diff --git a/packages/javascript/bh-shared-ui/package.json b/packages/javascript/bh-shared-ui/package.json index 73cc50ebe3..7e19a18398 100644 --- a/packages/javascript/bh-shared-ui/package.json +++ b/packages/javascript/bh-shared-ui/package.json @@ -19,7 +19,7 @@ "author": "The BloodHound Enterprise Team (https://bloodhoundenterprise.io/)", "license": "MIT", "dependencies": { - "@bloodhoundenterprise/doodleui": "^1.0.0-alpha.11", + "@bloodhoundenterprise/doodleui": "^1.0.0-alpha.12", "@fortawesome/fontawesome-free": "^6.4.2", "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx index 99771b6dd3..220df2ca0f 100644 --- a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.test.tsx @@ -31,7 +31,7 @@ const samlProvider: SSOProvider = { }; const oidcProvider: SSOProvider = { - id: 1, + id: 2, slug: 'gotham-oidc', name: 'Gotham OIDC', type: 'OIDC', diff --git a/packages/javascript/js-client-library/src/client.ts b/packages/javascript/js-client-library/src/client.ts index cca06147b4..1da62e1c05 100644 --- a/packages/javascript/js-client-library/src/client.ts +++ b/packages/javascript/js-client-library/src/client.ts @@ -290,9 +290,11 @@ class BHEAPIClient { `/api/v2/domains/${environmentId}/finding-trends`, Object.assign( { - start: start?.toISOString(), - end: end?.toISOString(), - sort_by, + params: { + start: start?.toISOString(), + end: end?.toISOString(), + sort_by, + }, }, options ) @@ -304,16 +306,16 @@ class BHEAPIClient { dataType: string, start?: Date, end?: Date, - partition_by?: string, options?: types.RequestOptions ) => { return this.baseClient.get( - `/api/v2/posture-history/${environmentId}/${dataType}`, + `/api/v2/domains/${environmentId}/posture-history/${dataType}`, Object.assign( { - start: start?.toISOString(), - end: end?.toISOString(), - partition_by, + params: { + start: start?.toISOString(), + end: end?.toISOString(), + }, }, options ) diff --git a/packages/javascript/js-client-library/src/responses.ts b/packages/javascript/js-client-library/src/responses.ts index 432b722ce7..3189499fea 100644 --- a/packages/javascript/js-client-library/src/responses.ts +++ b/packages/javascript/js-client-library/src/responses.ts @@ -114,15 +114,14 @@ export type PostureFindingTrendsResponse = TimeWindowedResponse<{ total_finding_count_end: number; }>; -type PostureHistoryAggregatedData = { +export type PostureHistoryData = { date: string; - min: number; - max: number; - average: number; - count: number; + value: number; }; -export type PostureHistoryResponse = { aggregation_data: PostureHistoryAggregatedData[] }; +export type PostureHistoryResponse = TimeWindowedResponse & { + data_type: string; +}; type DatapipeStatus = { status: 'idle' | 'ingesting' | 'analyzing' | 'purging'; diff --git a/yarn.lock b/yarn.lock index 8eb721dcf0..67fe334379 100644 --- a/yarn.lock +++ b/yarn.lock @@ -309,9 +309,9 @@ __metadata: languageName: node linkType: hard -"@bloodhoundenterprise/doodleui@npm:^1.0.0-alpha.11": - version: 1.0.0-alpha.11 - resolution: "@bloodhoundenterprise/doodleui@npm:1.0.0-alpha.11" +"@bloodhoundenterprise/doodleui@npm:^1.0.0-alpha.12": + version: 1.0.0-alpha.12 + resolution: "@bloodhoundenterprise/doodleui@npm:1.0.0-alpha.12" dependencies: "@radix-ui/react-accordion": ^1.1.2 "@radix-ui/react-checkbox": ^1.1.2 @@ -338,7 +338,7 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 tailwindcss: ^3.4.3 - checksum: 38acdc018226930a993361833946b689b23d39d18f3eca4ab590e93968340b171d8b33b026f295035621f64f90ee24b6d00347a67261ca25b7ac7fcd07f8cc7e + checksum: df3f49e535094fdb35349d5e8332a5ac5692ec3bac7fe31f4dd807f69ea21a48bb9d8f9a4e0f82a864135442214295e2d657c0fee54558b5535d98193d9e70ba languageName: node linkType: hard @@ -4235,7 +4235,7 @@ __metadata: version: 0.0.0-use.local resolution: "bh-shared-ui@workspace:packages/javascript/bh-shared-ui" dependencies: - "@bloodhoundenterprise/doodleui": ^1.0.0-alpha.11 + "@bloodhoundenterprise/doodleui": ^1.0.0-alpha.12 "@emotion/react": ^11.10.4 "@emotion/styled": ^11.10.4 "@fortawesome/fontawesome-free": ^6.4.2 @@ -4343,7 +4343,7 @@ __metadata: version: 0.0.0-use.local resolution: "bloodhound-ui@workspace:cmd/ui" dependencies: - "@bloodhoundenterprise/doodleui": ^1.0.0-alpha.11 + "@bloodhoundenterprise/doodleui": ^1.0.0-alpha.12 "@date-io/luxon": ^1.3.13 "@emotion/react": ^11.10.4 "@emotion/styled": ^11.10.4 From 6b9c6c0790a5ad8b072f354773c68ea7e79fd461 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Tue, 26 Nov 2024 20:28:56 -0800 Subject: [PATCH 13/18] feat: BED-5079 - add a count function for efficiently counting ingest tasks --- cmd/api/src/api/error.go | 2 +- cmd/api/src/database/db.go | 1 + cmd/api/src/database/ingest.go | 10 ++++++++++ cmd/api/src/database/mocks/db.go | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/api/src/api/error.go b/cmd/api/src/api/error.go index 867bd0b7e1..aa5afac6d7 100644 --- a/cmd/api/src/api/error.go +++ b/cmd/api/src/api/error.go @@ -54,7 +54,7 @@ const ( ErrorResponseDetailsOTPInvalid = "one time password is invalid" ErrorResponseDetailsResourceNotFound = "resource not found" ErrorResponseDetailsToBeforeFrom = "to time cannot be before from time" - ErrorResponseDetailsTimeRangeInvalid = "time range provided is invalid" + ErrorResponseDetailsTimeRangeInvalid = "time range provided is invalid" ErrorResponseDetailsToMalformed = "to parameter should be formatted as RFC3339 i.e 2021-04-21T07:20:50.52Z" ErrorResponseMultipleCollectionScopesProvided = "may only scope collection by exactly one of OU, Domain, or All Trusted Domains" ErrorResponsePayloadUnmarshalError = "error unmarshalling JSON payload" diff --git a/cmd/api/src/database/db.go b/cmd/api/src/database/db.go index b324a00b33..42e07ebe27 100644 --- a/cmd/api/src/database/db.go +++ b/cmd/api/src/database/db.go @@ -66,6 +66,7 @@ type Database interface { // Ingest ingest.IngestData GetAllIngestTasks(ctx context.Context) (model.IngestTasks, error) + CountAllIngestTasks(ctx context.Context) (int64, error) DeleteIngestTask(ctx context.Context, ingestTask model.IngestTask) error GetIngestTasksForJob(ctx context.Context, jobID int64) (model.IngestTasks, error) diff --git a/cmd/api/src/database/ingest.go b/cmd/api/src/database/ingest.go index 6104a76655..db6b956089 100644 --- a/cmd/api/src/database/ingest.go +++ b/cmd/api/src/database/ingest.go @@ -35,6 +35,16 @@ func (s *BloodhoundDB) GetAllIngestTasks(ctx context.Context) (model.IngestTasks return ingestTasks, CheckError(result) } +func (s *BloodhoundDB) CountAllIngestTasks(ctx context.Context) (int64, error) { + var ( + ingestTaskCount int64 + ingestTasksModel model.IngestTasks + ) + + result := s.db.Model(&ingestTasksModel).WithContext(ctx).Count(&ingestTaskCount) + return ingestTaskCount, CheckError(result) +} + func (s *BloodhoundDB) DeleteIngestTask(ctx context.Context, ingestTask model.IngestTask) error { result := s.db.WithContext(ctx).Delete(&ingestTask) return CheckError(result) diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 2482616a01..e4514fd1d3 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -95,6 +95,21 @@ func (mr *MockDatabaseMockRecorder) Close(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDatabase)(nil).Close), arg0) } +// CountAllIngestTasks mocks base method. +func (m *MockDatabase) CountAllIngestTasks(arg0 context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountAllIngestTasks", arg0) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountAllIngestTasks indicates an expected call of CountAllIngestTasks. +func (mr *MockDatabaseMockRecorder) CountAllIngestTasks(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAllIngestTasks", reflect.TypeOf((*MockDatabase)(nil).CountAllIngestTasks), arg0) +} + // CreateADDataQualityAggregation mocks base method. func (m *MockDatabase) CreateADDataQualityAggregation(arg0 context.Context, arg1 model.ADDataQualityAggregation) (model.ADDataQualityAggregation, error) { m.ctrl.T.Helper() From 70849cca5af6099230e8d9d076fbc962a0d50c5b Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Wed, 27 Nov 2024 14:02:39 -0600 Subject: [PATCH 14/18] BED-5022 Handle GetDatapipeStatus Errors (#976) * BED-5022 return all database errors from GetDatapipeStatus & GetAnalysisRequest. Added unit tests for the associated endpoints --- cmd/api/src/api/v2/analysisrequest.go | 4 +- .../v2/analysisrequest_integration_test.go | 39 ++++++++++++ cmd/api/src/api/v2/analysisrequest_test.go | 57 ++++++++++++++---- .../src/api/v2/datapipe_integration_test.go | 39 ++++++++++++ cmd/api/src/api/v2/datapipe_test.go | 60 +++++++++++++++---- cmd/api/src/database/analysisrequest.go | 20 +++---- cmd/api/src/database/analysisrequest_test.go | 4 +- cmd/api/src/database/datapipestatus.go | 13 ++-- cmd/api/src/database/db.go | 3 +- 9 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 cmd/api/src/api/v2/analysisrequest_integration_test.go create mode 100644 cmd/api/src/api/v2/datapipe_integration_test.go diff --git a/cmd/api/src/api/v2/analysisrequest.go b/cmd/api/src/api/v2/analysisrequest.go index d430c08aa9..efdae9533d 100644 --- a/cmd/api/src/api/v2/analysisrequest.go +++ b/cmd/api/src/api/v2/analysisrequest.go @@ -18,9 +18,9 @@ package v2 import ( "database/sql" + "errors" "net/http" - "github.com/specterops/bloodhound/errors" "github.com/specterops/bloodhound/log" "github.com/specterops/bloodhound/src/api" "github.com/specterops/bloodhound/src/auth" @@ -33,8 +33,6 @@ const ErrAnalysisScheduledMode = "analysis is configured to run on a schedule, u func (s Resources) GetAnalysisRequest(response http.ResponseWriter, request *http.Request) { if analRequest, err := s.DB.GetAnalysisRequest(request.Context()); err != nil && !errors.Is(err, sql.ErrNoRows) { api.HandleDatabaseError(request, response, err) - } else if errors.Is(err, sql.ErrNoRows) { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response) } else { api.WriteBasicResponse(request.Context(), analRequest, http.StatusOK, response) } diff --git a/cmd/api/src/api/v2/analysisrequest_integration_test.go b/cmd/api/src/api/v2/analysisrequest_integration_test.go new file mode 100644 index 0000000000..873fdf209c --- /dev/null +++ b/cmd/api/src/api/v2/analysisrequest_integration_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package v2_test + +import ( + "testing" + + "github.com/specterops/bloodhound/src/api/v2/integration" + "github.com/specterops/bloodhound/src/model" + "github.com/stretchr/testify/require" +) + +func TestRequestAnalysis(t *testing.T) { + testCtx := integration.NewFOSSContext(t) + + err := testCtx.AdminClient().RequestAnalysis() + require.Nil(t, err) + + analReq, err := testCtx.AdminClient().GetAnalysisRequest() + require.Nil(t, err) + require.Equal(t, analReq.RequestType, model.AnalysisRequestAnalysis) +} diff --git a/cmd/api/src/api/v2/analysisrequest_test.go b/cmd/api/src/api/v2/analysisrequest_test.go index 873fdf209c..64b29d968a 100644 --- a/cmd/api/src/api/v2/analysisrequest_test.go +++ b/cmd/api/src/api/v2/analysisrequest_test.go @@ -14,26 +14,59 @@ // // SPDX-License-Identifier: Apache-2.0 -//go:build integration -// +build integration - package v2_test import ( + "fmt" + "net/http" "testing" + "time" - "github.com/specterops/bloodhound/src/api/v2/integration" + v2 "github.com/specterops/bloodhound/src/api/v2" + dbMocks "github.com/specterops/bloodhound/src/database/mocks" "github.com/specterops/bloodhound/src/model" - "github.com/stretchr/testify/require" + "github.com/specterops/bloodhound/src/utils/test" + "go.uber.org/mock/gomock" ) -func TestRequestAnalysis(t *testing.T) { - testCtx := integration.NewFOSSContext(t) +func TestResources_GetAnalysisRequest(t *testing.T) { + const ( + url = "api/v2/analysis/status" + ) + + var ( + mockCtrl = gomock.NewController(t) + mockDB = dbMocks.NewMockDatabase(mockCtrl) + resources = v2.Resources{DB: mockDB} + ) + defer mockCtrl.Finish() + + t.Run("success getting analysis", func(t *testing.T) { + analysisRequest := model.AnalysisRequest{ + RequestedAt: time.Now(), + RequestedBy: "test", + RequestType: model.AnalysisRequestType("test-type"), + } + + mockDB.EXPECT().GetAnalysisRequest(gomock.Any()).Return(analysisRequest, nil) + + test.Request(t). + WithMethod(http.MethodGet). + WithURL(url). + OnHandlerFunc(resources.GetAnalysisRequest). + Require(). + ResponseJSONBody(analysisRequest). + ResponseStatusCode(http.StatusOK) + }) - err := testCtx.AdminClient().RequestAnalysis() - require.Nil(t, err) + t.Run("error getting analysis", func(t *testing.T) { + mockDB.EXPECT().GetAnalysisRequest(gomock.Any()).Return(model.AnalysisRequest{}, fmt.Errorf("an error")) - analReq, err := testCtx.AdminClient().GetAnalysisRequest() - require.Nil(t, err) - require.Equal(t, analReq.RequestType, model.AnalysisRequestAnalysis) + test.Request(t). + WithMethod(http.MethodGet). + WithURL(url). + OnHandlerFunc(resources.GetAnalysisRequest). + Require(). + ResponseStatusCode(http.StatusInternalServerError) + }) } diff --git a/cmd/api/src/api/v2/datapipe_integration_test.go b/cmd/api/src/api/v2/datapipe_integration_test.go new file mode 100644 index 0000000000..e992a312ae --- /dev/null +++ b/cmd/api/src/api/v2/datapipe_integration_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDXLicenseIdentifier: Apache2.0 + +//go:build serial_integration +// +build serial_integration + +package v2_test + +import ( + "testing" + "time" + + "github.com/specterops/bloodhound/src/api/v2/integration" + "github.com/specterops/bloodhound/src/model" + "github.com/stretchr/testify/require" +) + +func TestGetDatapipeStatus(t *testing.T) { + testCtx := integration.NewFOSSContext(t) + + testCtx.WaitForDatapipeIdle(90 * time.Second) + + datapipeStatus, err := testCtx.AdminClient().GetDatapipeStatus() + require.Nil(t, err) + require.Equal(t, datapipeStatus.Status, model.DatapipeStatusIdle) +} diff --git a/cmd/api/src/api/v2/datapipe_test.go b/cmd/api/src/api/v2/datapipe_test.go index 8caf3500a4..f4226c6e0e 100644 --- a/cmd/api/src/api/v2/datapipe_test.go +++ b/cmd/api/src/api/v2/datapipe_test.go @@ -14,26 +14,64 @@ // // SPDX-License-Identifier: Apache-2.0 -//go:build serial_integration -// +build serial_integration - package v2_test import ( + "fmt" + "net/http" "testing" "time" - "github.com/specterops/bloodhound/src/api/v2/integration" + v2 "github.com/specterops/bloodhound/src/api/v2" + dbMocks "github.com/specterops/bloodhound/src/database/mocks" "github.com/specterops/bloodhound/src/model" - "github.com/stretchr/testify/require" + "github.com/specterops/bloodhound/src/utils/test" + "go.uber.org/mock/gomock" ) -func TestGetDatapipeStatus(t *testing.T) { - testCtx := integration.NewFOSSContext(t) +func TestResources_GetDatapipeStatus(t *testing.T) { + const ( + url = "api/v2/datapipe/status" + ) + + var ( + mockCtrl = gomock.NewController(t) + mockDB = dbMocks.NewMockDatabase(mockCtrl) + resources = v2.Resources{DB: mockDB} + ) + defer mockCtrl.Finish() + + t.Run("success getting datapipe status", func(t *testing.T) { + lastCompleteAnalysisAt := time.Now() + lastAnalysisRunAt := time.Now().Add(-time.Minute) + + mockDB.EXPECT().GetDatapipeStatus(gomock.Any()).Return(model.DatapipeStatusWrapper{ + Status: "idle", + LastCompleteAnalysisAt: lastCompleteAnalysisAt, + LastAnalysisRunAt: lastAnalysisRunAt, + }, nil) + + test.Request(t). + WithMethod(http.MethodGet). + WithURL(url). + OnHandlerFunc(resources.GetDatapipeStatus). + Require(). + ResponseJSONBody(model.DatapipeStatusWrapper{ + Status: "idle", + LastCompleteAnalysisAt: lastCompleteAnalysisAt, + LastAnalysisRunAt: lastAnalysisRunAt, + }). + ResponseStatusCode(200) + }) - testCtx.WaitForDatapipeIdle(90 * time.Second) + t.Run("error getting datapipe status", func(t *testing.T) { + mockDB.EXPECT().GetDatapipeStatus(gomock.Any()).Return(model.DatapipeStatusWrapper{}, fmt.Errorf("an error")) - datapipeStatus, err := testCtx.AdminClient().GetDatapipeStatus() - require.Nil(t, err) - require.Equal(t, datapipeStatus.Status, model.DatapipeStatusIdle) + test.Request(t). + WithMethod(http.MethodGet). + WithURL(url). + OnHandlerFunc(resources.GetDatapipeStatus). + Require(). + ResponseStatusCode(http.StatusInternalServerError) + }) } diff --git a/cmd/api/src/database/analysisrequest.go b/cmd/api/src/database/analysisrequest.go index 91650bf84a..e0f5c2eb61 100644 --- a/cmd/api/src/database/analysisrequest.go +++ b/cmd/api/src/database/analysisrequest.go @@ -18,10 +18,9 @@ package database import ( "context" - "database/sql" + "errors" "time" - "github.com/specterops/bloodhound/errors" "github.com/specterops/bloodhound/log" "github.com/specterops/bloodhound/src/model" ) @@ -43,12 +42,9 @@ func (s *BloodhoundDB) DeleteAnalysisRequest(ctx context.Context) error { func (s *BloodhoundDB) GetAnalysisRequest(ctx context.Context) (model.AnalysisRequest, error) { var analysisRequest model.AnalysisRequest - // Note: GORM Raw does not throw any errors if no row is found. We can inspect rows affected as a workaround - if tx := s.db.WithContext(ctx).Raw(`select requested_by, request_type, requested_at from analysis_request_switch limit 1;`).Scan(&analysisRequest); tx.RowsAffected == 0 { - return analysisRequest, sql.ErrNoRows - } + tx := s.db.WithContext(ctx).Select("requested_by, request_type, requested_at").Table("analysis_request_switch").First(&analysisRequest) - return analysisRequest, nil + return analysisRequest, CheckError(tx) } func (s *BloodhoundDB) HasAnalysisRequest(ctx context.Context) bool { @@ -71,14 +67,14 @@ func (s *BloodhoundDB) HasCollectedGraphDataDeletionRequest(ctx context.Context) return exists } -// This inserts a row into analysis_request_switch for both a collected graph data deletion request or an analysis request. +// setAnalysisRequest inserts a row into analysis_request_switch for both a collected graph data deletion request or an analysis request. // There should only ever be 1 row, if a request is present, subsequent requests no-op // If an analysis request is present when a deletion request comes in, that overwrites the analysis to deletion but not vice-versa // To request: Use the helper methods `RequestAnalysis` and `RequestCollectedGraphDataDeletion` func (s *BloodhoundDB) setAnalysisRequest(ctx context.Context, requestType model.AnalysisRequestType, requestedBy string) error { - if analReq, err := s.GetAnalysisRequest(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { + if analReq, err := s.GetAnalysisRequest(ctx); err != nil && !errors.Is(err, ErrNotFound) { return err - } else if errors.Is(err, sql.ErrNoRows) { + } else if errors.Is(err, ErrNotFound) { // Analysis request doesn't exist so insert one insertSql := `insert into analysis_request_switch (requested_by, request_type, requested_at) values (?, ?, ?);` tx := s.db.WithContext(ctx).Exec(insertSql, requestedBy, requestType, time.Now().UTC()) @@ -94,13 +90,13 @@ func (s *BloodhoundDB) setAnalysisRequest(ctx context.Context, requestType model } } -// This will request an analysis be executed, as long as there isn't an existing analysis request or collected graph data deletion request, then it no-ops +// RequestAnalysis will request an analysis be executed, as long as there isn't an existing analysis request or collected graph data deletion request, then it no-ops func (s *BloodhoundDB) RequestAnalysis(ctx context.Context, requestedBy string) error { log.Infof("Analysis requested by %s", requestedBy) return s.setAnalysisRequest(ctx, model.AnalysisRequestAnalysis, requestedBy) } -// This will request collected graph data be deleted, if an analysis request is present, it will overwrite that. +// RequestCollectedGraphDataDeletion will request collected graph data be deleted, if an analysis request is present, it will overwrite that. func (s *BloodhoundDB) RequestCollectedGraphDataDeletion(ctx context.Context, requestedBy string) error { log.Infof("Collected graph data deletion requested by %s", requestedBy) return s.setAnalysisRequest(ctx, model.AnalysisRequestDeletion, requestedBy) diff --git a/cmd/api/src/database/analysisrequest_test.go b/cmd/api/src/database/analysisrequest_test.go index ef3571b066..ceb36bd902 100644 --- a/cmd/api/src/database/analysisrequest_test.go +++ b/cmd/api/src/database/analysisrequest_test.go @@ -21,9 +21,9 @@ package database_test import ( "context" - "database/sql" "testing" + "github.com/specterops/bloodhound/src/database" "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/test/integration" "github.com/stretchr/testify/require" @@ -48,5 +48,5 @@ func TestAnalysisRequest(t *testing.T) { require.Nil(t, err) _, err = dbInst.GetAnalysisRequest(testCtx) - require.ErrorIs(t, err, sql.ErrNoRows) + require.ErrorIs(t, err, database.ErrNotFound) } diff --git a/cmd/api/src/database/datapipestatus.go b/cmd/api/src/database/datapipestatus.go index e003066c48..bf771bb458 100644 --- a/cmd/api/src/database/datapipestatus.go +++ b/cmd/api/src/database/datapipestatus.go @@ -18,12 +18,16 @@ package database import ( "context" - "database/sql" "time" "github.com/specterops/bloodhound/src/model" ) +type DatapipeStatusData interface { + SetDatapipeStatus(ctx context.Context, status model.DatapipeStatus, updateAnalysisTime bool) error + GetDatapipeStatus(ctx context.Context) (model.DatapipeStatusWrapper, error) +} + func (s *BloodhoundDB) SetDatapipeStatus(ctx context.Context, status model.DatapipeStatus, updateAnalysisTime bool) error { now := time.Now().UTC() // All queries will update the status and table update time @@ -42,15 +46,12 @@ func (s *BloodhoundDB) SetDatapipeStatus(ctx context.Context, status model.Datap updateSql += ";" return s.db.WithContext(ctx).Exec(updateSql, status, now).Error } - } func (s *BloodhoundDB) GetDatapipeStatus(ctx context.Context) (model.DatapipeStatusWrapper, error) { var datapipeStatus model.DatapipeStatusWrapper - if tx := s.db.WithContext(ctx).Raw("SELECT status, updated_at, last_complete_analysis_at, last_analysis_run_at FROM datapipe_status LIMIT 1;").Scan(&datapipeStatus); tx.RowsAffected == 0 { - return datapipeStatus, sql.ErrNoRows - } + tx := s.db.WithContext(ctx).Select("status, updated_at, last_complete_analysis_at, last_analysis_run_at").Table("datapipe_status").First(&datapipeStatus) - return datapipeStatus, nil + return datapipeStatus, CheckError(tx) } diff --git a/cmd/api/src/database/db.go b/cmd/api/src/database/db.go index 42e07ebe27..19c792f2c5 100644 --- a/cmd/api/src/database/db.go +++ b/cmd/api/src/database/db.go @@ -159,8 +159,7 @@ type Database interface { AnalysisRequestData // Datapipe Status - SetDatapipeStatus(ctx context.Context, status model.DatapipeStatus, updateAnalysisTime bool) error - GetDatapipeStatus(ctx context.Context) (model.DatapipeStatusWrapper, error) + DatapipeStatusData } type BloodhoundDB struct { From ff2a2d52bd31c09b83a1b6b787df60124c0513a9 Mon Sep 17 00:00:00 2001 From: Ulises Rangel Date: Wed, 27 Nov 2024 15:34:08 -0600 Subject: [PATCH 15/18] chore: prep review changes (#988) --- cmd/api/src/model/samlprovider.go | 4 ++-- packages/go/analysis/post_integration_test.go | 16 ++++++++++++++++ packages/go/graphschema/ad/ad.go | 1 + packages/go/graphschema/azure/azure.go | 1 + packages/go/graphschema/common/common.go | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/api/src/model/samlprovider.go b/cmd/api/src/model/samlprovider.go index 480e9b002f..6cffc17bdc 100644 --- a/cmd/api/src/model/samlprovider.go +++ b/cmd/api/src/model/samlprovider.go @@ -46,7 +46,7 @@ var ( SAMLRootURIVersion1 SAMLRootURIVersion = 1 SAMLRootURIVersion2 SAMLRootURIVersion = 2 - SAMLRootURIVersionMap = map[SAMLRootURIVersion]string { + SAMLRootURIVersionMap = map[SAMLRootURIVersion]string{ SAMLRootURIVersion1: "/api/v1/login/saml", SAMLRootURIVersion2: "/api/v2/sso", } @@ -147,7 +147,7 @@ func (s SAMLProvider) GetSAMLUserPrincipalNameFromAssertion(assertion *saml.Asse func (s *SAMLProvider) FormatSAMLProviderURLs(hostUrl url.URL) { root := hostUrl - root.Path = path.Join(SAMLRootURIVersionMap[s.RootURIVersion], s.Name) + root.Path = path.Join(SAMLRootURIVersionMap[s.RootURIVersion], s.Name) // To preserve existing IDP configurations, existing saml providers still use the old acs endpoint which redirects to the new callback handler switch s.RootURIVersion { diff --git a/packages/go/analysis/post_integration_test.go b/packages/go/analysis/post_integration_test.go index a82d81be37..426e7b5981 100644 --- a/packages/go/analysis/post_integration_test.go +++ b/packages/go/analysis/post_integration_test.go @@ -1,3 +1,19 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + //go:build serial_integration // +build serial_integration diff --git a/packages/go/graphschema/ad/ad.go b/packages/go/graphschema/ad/ad.go index 4a948558ff..5dad6bea8d 100644 --- a/packages/go/graphschema/ad/ad.go +++ b/packages/go/graphschema/ad/ad.go @@ -21,6 +21,7 @@ package ad import ( "errors" + graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go index 00b20f190f..787ee392e6 100644 --- a/packages/go/graphschema/azure/azure.go +++ b/packages/go/graphschema/azure/azure.go @@ -21,6 +21,7 @@ package azure import ( "errors" + graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/common/common.go b/packages/go/graphschema/common/common.go index 631871c6bf..73edf123fa 100644 --- a/packages/go/graphschema/common/common.go +++ b/packages/go/graphschema/common/common.go @@ -21,6 +21,7 @@ package common import ( "errors" + graph "github.com/specterops/bloodhound/dawgs/graph" ) From 929f2517d260b6bb243198aae66a339734784c0e Mon Sep 17 00:00:00 2001 From: mistahj67 <26472282+mistahj67@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:05:14 -0700 Subject: [PATCH 16/18] BED-5067 feat: add edit sso provider support to ui (#978) --- packages/go/openapi/doc/openapi.json | 96 ++++++++++- .../src/paths/sso.sso-providers.id.yaml | 60 +++++++ .../src/paths/sso.sso-providers.oidc.yaml | 2 +- .../components/CreateOIDCProviderDialog.tsx | 159 ------------------ .../SSOProviderTable/SSOProviderTable.tsx | 32 +++- .../components/UpsertOIDCProviderDialog.tsx | 49 ++++++ .../src/components/UpsertOIDCProviderForm.tsx | 139 +++++++++++++++ .../UpsertSAMLProviderDialog.tsx} | 22 ++- .../index.ts | 4 +- .../UpsertSAMLProviderForm.test.tsx} | 12 +- .../UpsertSAMLProviderForm.tsx} | 28 ++- .../index.ts | 4 +- .../bh-shared-ui/src/components/index.ts | 12 +- .../SSOConfiguration/SSOConfiguration.tsx | 96 ++++++++--- .../js-client-library/src/client.ts | 42 ++--- .../javascript/js-client-library/src/types.ts | 9 + 16 files changed, 508 insertions(+), 258 deletions(-) delete mode 100644 packages/javascript/bh-shared-ui/src/components/CreateOIDCProviderDialog.tsx create mode 100644 packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderDialog.tsx create mode 100644 packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderForm.tsx rename packages/javascript/bh-shared-ui/src/components/{CreateSAMLProviderDialog/CreateSAMLProviderDialog.tsx => UpsertSAMLProviderDialog/UpsertSAMLProviderDialog.tsx} (60%) rename packages/javascript/bh-shared-ui/src/components/{CreateSAMLProviderDialog => UpsertSAMLProviderDialog}/index.ts (86%) rename packages/javascript/bh-shared-ui/src/components/{CreateSAMLProviderForm/CreateSAMLProviderForm.test.tsx => UpsertSAMLProviderForm/UpsertSAMLProviderForm.test.tsx} (88%) rename packages/javascript/bh-shared-ui/src/components/{CreateSAMLProviderForm/CreateSAMLProviderForm.tsx => UpsertSAMLProviderForm/UpsertSAMLProviderForm.tsx} (90%) rename packages/javascript/bh-shared-ui/src/components/{CreateSAMLProviderForm => UpsertSAMLProviderForm}/index.ts (86%) diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index 75c1fe361b..31b8cb071f 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -605,7 +605,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/model.auth-provider" + "$ref": "#/components/schemas/model.oidc-provider" } } } @@ -646,6 +646,100 @@ } } ], + "patch": { + "operationId": "PatchSSOProvider", + "summary": "Update SSO Provider", + "description": "Updates an existing SSO provider. Updating saml provider requires a \"multipart/form-data\" body. Updating oidc provider requires \"application/json\" body. Response is respective provider", + "tags": [ + "Auth", + "Community", + "Enterprise" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "Name of the new SAML provider." + }, + "metadata": { + "type": "string", + "format": "binary", + "description": "Metadata XML file." + } + } + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the OIDC provider" + }, + "issuer": { + "type": "string", + "format": "url", + "description": "URL of the OIDC issuer" + }, + "client_id": { + "type": "string", + "description": "Client ID for the OIDC provider" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/model.saml-provider" + } + } + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/model.oidc-provider" + } + } + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + } + }, "delete": { "operationId": "DeleteSSOProvider", "summary": "Delete SSO Provider", diff --git a/packages/go/openapi/src/paths/sso.sso-providers.id.yaml b/packages/go/openapi/src/paths/sso.sso-providers.id.yaml index e174da4166..0a82e816fc 100644 --- a/packages/go/openapi/src/paths/sso.sso-providers.id.yaml +++ b/packages/go/openapi/src/paths/sso.sso-providers.id.yaml @@ -23,6 +23,66 @@ parameters: schema: type: integer format: int32 +patch: + operationId: PatchSSOProvider + summary: Update SSO Provider + description: Updates an existing SSO provider. Updating saml provider requires a "multipart/form-data" body. Updating oidc provider requires "application/json" body. Response is respective provider + tags: + - Auth + - Community + - Enterprise + requestBody: + required: true + content: + multipart/form-data: + schema: + properties: + name: + type: string + description: Name of the new SAML provider. + metadata: + type: string + format: binary + description: Metadata XML file. + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the OIDC provider + issuer: + type: string + format: url + description: URL of the OIDC issuer + client_id: + type: string + description: Client ID for the OIDC provider + responses: + '200': + description: OK + content: + application/json: + schema: + oneOf: + - type: object + properties: + data: + $ref: './../schemas/model.saml-provider.yaml' + - type: object + properties: + data: + $ref: './../schemas/model.oidc-provider.yaml' + '401': + $ref: './../responses/unauthorized.yaml' + '403': + $ref: './../responses/forbidden.yaml' + '404': + $ref: './../responses/not-found.yaml' + '429': + $ref: './../responses/too-many-requests.yaml' + '500': + $ref: './../responses/internal-server-error.yaml' delete: operationId: DeleteSSOProvider summary: Delete SSO Provider diff --git a/packages/go/openapi/src/paths/sso.sso-providers.oidc.yaml b/packages/go/openapi/src/paths/sso.sso-providers.oidc.yaml index d9a71bea4f..777ad47cdc 100644 --- a/packages/go/openapi/src/paths/sso.sso-providers.oidc.yaml +++ b/packages/go/openapi/src/paths/sso.sso-providers.oidc.yaml @@ -54,7 +54,7 @@ post: type: object properties: data: - $ref: './../schemas/model.auth-provider.yaml' + $ref: './../schemas/model.oidc-provider.yaml' '400': $ref: './../responses/bad-request.yaml' '401': diff --git a/packages/javascript/bh-shared-ui/src/components/CreateOIDCProviderDialog.tsx b/packages/javascript/bh-shared-ui/src/components/CreateOIDCProviderDialog.tsx deleted file mode 100644 index f5e5f91600..0000000000 --- a/packages/javascript/bh-shared-ui/src/components/CreateOIDCProviderDialog.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2024 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -import { Button } from '@bloodhoundenterprise/doodleui'; -import { Alert, Dialog, DialogTitle, DialogContent, DialogActions, Grid, TextField } from '@mui/material'; -import { FC } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { CreateOIDCProviderRequest } from 'js-client-library'; - -const CreateOIDCProviderDialog: FC<{ - open: boolean; - error?: string; - onClose: () => void; - onSubmit: (data: CreateOIDCProviderRequest) => void; -}> = ({ open, error, onClose, onSubmit: _onSubmit }) => { - const { - control, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - defaultValues: { - name: '', - client_id: '', - issuer: '', - }, - }); - - const onSubmit = (data: CreateOIDCProviderRequest) => { - _onSubmit(data); - reset(); - }; - - const handleClose = () => { - onClose(); - reset(); - }; - - return ( - - Create OIDC Provider -
- - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - {error && ( - - {error} - - )} - - - - - - -
-
- ); -}; - -export default CreateOIDCProviderDialog; diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx index b2bfb55de9..852e106ceb 100644 --- a/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx +++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderTable/SSOProviderTable.tsx @@ -15,7 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Button } from '@bloodhoundenterprise/doodleui'; -import { faEllipsisVertical, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faEdit, faEllipsisVertical, faTrash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconButton, @@ -61,7 +61,8 @@ const StyledMenu = withStyles({ const SSOProviderTableActionsMenu: FC<{ onDeleteSSOProvider: () => void; -}> = ({ onDeleteSSOProvider }) => { + onUpdateSSOProvider: () => void; +}> = ({ onDeleteSSOProvider, onUpdateSSOProvider }) => { /* Hooks */ const [anchorEl, setAnchorEl] = useState(null); @@ -79,6 +80,11 @@ const SSOProviderTableActionsMenu: FC<{ setAnchorEl(null); }; + const onClickUpdateSSOProvider = () => { + onUpdateSSOProvider(); + setAnchorEl(null); + }; + return ( <> @@ -97,6 +103,12 @@ const SSOProviderTableActionsMenu: FC<{ + + + + + + ); @@ -105,11 +117,20 @@ const SSOProviderTableActionsMenu: FC<{ const SSOProviderTable: FC<{ ssoProviders: SSOProvider[]; loading: boolean; - onDeleteSSOProvider: (ssoProviderId: SSOProvider['id']) => void; + onDeleteSSOProvider: (ssoProvider: SSOProvider) => void; + onUpdateSSOProvider: (ssoProvider: SSOProvider) => void; onClickSSOProvider: (ssoProviderId: SSOProvider['id']) => void; onToggleTypeSortOrder: () => void; typeSortOrder?: SortOrder; -}> = ({ ssoProviders, loading, onDeleteSSOProvider, onClickSSOProvider, typeSortOrder, onToggleTypeSortOrder }) => { +}> = ({ + ssoProviders, + loading, + onDeleteSSOProvider, + onUpdateSSOProvider, + onClickSSOProvider, + onToggleTypeSortOrder, + typeSortOrder, +}) => { const theme = useTheme(); return ( @@ -178,7 +199,8 @@ const SSOProviderTable: FC<{ onDeleteSSOProvider(ssoProvider.id)} + onDeleteSSOProvider={() => onDeleteSSOProvider(ssoProvider)} + onUpdateSSOProvider={() => onUpdateSSOProvider(ssoProvider)} /> onClickSSOProvider(ssoProvider.id)} size='small'> diff --git a/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderDialog.tsx b/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderDialog.tsx new file mode 100644 index 0000000000..3cda8ded0e --- /dev/null +++ b/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderDialog.tsx @@ -0,0 +1,49 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { Dialog, DialogTitle } from '@mui/material'; +import { SSOProvider, UpsertOIDCProviderRequest } from 'js-client-library'; +import UpsertOIDCProviderForm from './UpsertOIDCProviderForm'; + +const UpsertOIDCProviderDialog: React.FC<{ + open: boolean; + error?: string; + oldSSOProvider?: SSOProvider; + onClose: () => void; + onSubmit: (data: UpsertOIDCProviderRequest) => void; +}> = ({ open, error, oldSSOProvider, onClose, onSubmit }) => { + return ( + + {oldSSOProvider ? 'Edit' : 'Create'} OIDC Provider + + + ); +}; + +export default UpsertOIDCProviderDialog; diff --git a/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderForm.tsx b/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderForm.tsx new file mode 100644 index 0000000000..b99c8ec04b --- /dev/null +++ b/packages/javascript/bh-shared-ui/src/components/UpsertOIDCProviderForm.tsx @@ -0,0 +1,139 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from '@bloodhoundenterprise/doodleui'; +import { Alert, DialogContent, DialogActions, Grid, TextField } from '@mui/material'; +import { FC } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { OIDCProviderInfo, SSOProvider, UpsertOIDCProviderRequest } from 'js-client-library'; + +const UpsertOIDCProviderForm: FC<{ + error?: string; + oldSSOProvider?: SSOProvider; + onClose: () => void; + onSubmit: (data: UpsertOIDCProviderRequest) => void; +}> = ({ error, oldSSOProvider, onClose, onSubmit }) => { + const defaultValues = { + name: oldSSOProvider?.name ?? '', + client_id: (oldSSOProvider?.details as OIDCProviderInfo)?.client_id ?? '', + issuer: (oldSSOProvider?.details as OIDCProviderInfo)?.issuer ?? '', + }; + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues }); + + const handleClose = () => { + onClose(); + reset(); + }; + + return ( +
+ + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + {error && ( + + {error} + + )} + + + + + + +
+ ); +}; + +export default UpsertOIDCProviderForm; diff --git a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/CreateSAMLProviderDialog.tsx b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/UpsertSAMLProviderDialog.tsx similarity index 60% rename from packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/CreateSAMLProviderDialog.tsx rename to packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/UpsertSAMLProviderDialog.tsx index 0cef28fe55..cfc85ed2e1 100644 --- a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/CreateSAMLProviderDialog.tsx +++ b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/UpsertSAMLProviderDialog.tsx @@ -15,15 +15,16 @@ // SPDX-License-Identifier: Apache-2.0 import { Dialog, DialogTitle } from '@mui/material'; -import CreateSAMLProviderForm from '../CreateSAMLProviderForm'; -import { CreateSAMLProviderFormInputs } from '../CreateSAMLProviderForm/CreateSAMLProviderForm'; +import { SSOProvider, UpsertSAMLProviderFormInputs } from 'js-client-library'; +import UpsertSAMLProviderForm from '../UpsertSAMLProviderForm'; -const CreateSAMLProviderDialog: React.FC<{ +const UpsertSAMLProviderDialog: React.FC<{ open: boolean; error?: string; + oldSSOProvider?: SSOProvider; onClose: () => void; - onSubmit: (data: CreateSAMLProviderFormInputs) => void; -}> = ({ open, error, onClose, onSubmit }) => { + onSubmit: (data: UpsertSAMLProviderFormInputs) => void; +}> = ({ open, error, oldSSOProvider, onClose, onSubmit }) => { return ( - Create SAML Provider - + {oldSSOProvider ? 'Edit' : 'Create'} SAML Provider + ); }; -export default CreateSAMLProviderDialog; +export default UpsertSAMLProviderDialog; diff --git a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/index.ts b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/index.ts similarity index 86% rename from packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/index.ts rename to packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/index.ts index 42cdc842b2..042c9c18a2 100644 --- a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderDialog/index.ts +++ b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderDialog/index.ts @@ -14,5 +14,5 @@ // // SPDX-License-Identifier: Apache-2.0 -export * from './CreateSAMLProviderDialog'; -export { default } from './CreateSAMLProviderDialog'; +export * from './UpsertSAMLProviderDialog'; +export { default } from './UpsertSAMLProviderDialog'; diff --git a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.test.tsx b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.test.tsx similarity index 88% rename from packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.test.tsx rename to packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.test.tsx index 9cda4c633d..518af2ec47 100644 --- a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.test.tsx @@ -16,13 +16,13 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '../../test-utils'; -import CreateSAMLProviderForm from './CreateSAMLProviderForm'; +import UpsertSAMLProviderForm from './UpsertSAMLProviderForm'; -describe('CreateSAMLProviderForm', () => { +describe('UpsertSAMLProviderForm', () => { it('should render inputs, labels, and action buttons', () => { const testOnClose = vi.fn(); const testOnSubmit = vi.fn(); - render(); + render(); expect(screen.getByLabelText('SAML Provider Name')).toBeInTheDocument(); @@ -37,7 +37,7 @@ describe('CreateSAMLProviderForm', () => { const user = userEvent.setup(); const testOnClose = vi.fn(); const testOnSubmit = vi.fn(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'Cancel' })); @@ -48,7 +48,7 @@ describe('CreateSAMLProviderForm', () => { const user = userEvent.setup(); const testOnClose = vi.fn(); const testOnSubmit = vi.fn(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'Submit' })); @@ -65,7 +65,7 @@ describe('CreateSAMLProviderForm', () => { const testOnSubmit = vi.fn(); const validProviderName = 'test-provider-name'; const validMetadata = new File([], 'test-metadata.xml'); - render(); + render(); await user.type(screen.getByLabelText('SAML Provider Name'), validProviderName); diff --git a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.tsx b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.tsx similarity index 90% rename from packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.tsx rename to packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.tsx index 4eba8a6bc2..af8d98fef6 100644 --- a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/CreateSAMLProviderForm.tsx +++ b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/UpsertSAMLProviderForm.tsx @@ -28,27 +28,23 @@ import { } from '@mui/material'; import { useState, FC } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { SSOProvider, UpsertSAMLProviderFormInputs } from 'js-client-library'; -export interface CreateSAMLProviderFormInputs { - name: string; - metadata: FileList; -} - -const CreateSAMLProviderForm: FC<{ +const UpsertSAMLProviderForm: FC<{ error?: string; + oldSSOProvider?: SSOProvider; onClose: () => void; - onSubmit: (data: CreateSAMLProviderFormInputs) => void; -}> = ({ error, onClose, onSubmit }) => { + onSubmit: (data: UpsertSAMLProviderFormInputs) => void; +}> = ({ error, onClose, oldSSOProvider, onSubmit }) => { const theme = useTheme(); const { control, handleSubmit, reset, - formState: { errors }, - } = useForm({ + } = useForm({ defaultValues: { - name: '', + name: oldSSOProvider?.name ?? '', metadata: undefined, }, }); @@ -61,7 +57,7 @@ const CreateSAMLProviderForm: FC<{ }; return ( -
+ @@ -96,9 +92,7 @@ const CreateSAMLProviderForm: FC<{ ( @@ -148,11 +142,11 @@ const CreateSAMLProviderForm: FC<{ Cancel ); }; -export default CreateSAMLProviderForm; +export default UpsertSAMLProviderForm; diff --git a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/index.ts b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/index.ts similarity index 86% rename from packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/index.ts rename to packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/index.ts index d11eb29d7c..0da797b75a 100644 --- a/packages/javascript/bh-shared-ui/src/components/CreateSAMLProviderForm/index.ts +++ b/packages/javascript/bh-shared-ui/src/components/UpsertSAMLProviderForm/index.ts @@ -14,5 +14,5 @@ // // SPDX-License-Identifier: Apache-2.0 -export * from './CreateSAMLProviderForm'; -export { default } from './CreateSAMLProviderForm'; +export * from './UpsertSAMLProviderForm'; +export { default } from './UpsertSAMLProviderForm'; diff --git a/packages/javascript/bh-shared-ui/src/components/index.ts b/packages/javascript/bh-shared-ui/src/components/index.ts index 38bb788996..5d6919bc43 100644 --- a/packages/javascript/bh-shared-ui/src/components/index.ts +++ b/packages/javascript/bh-shared-ui/src/components/index.ts @@ -49,12 +49,6 @@ export { default as ConfirmationDialog } from './ConfirmationDialog'; export * from './CreateMenu'; export { default as CreateMenu } from './CreateMenu'; -export * from './CreateSAMLProviderDialog'; -export { default as CreateSAMLProviderDialog } from './CreateSAMLProviderDialog'; - -export * from './CreateSAMLProviderForm'; -export { default as CreateSAMLProviderForm } from './CreateSAMLProviderForm'; - export * from './CreateUserForm'; export { default as CreateUserForm } from './CreateUserForm'; @@ -176,6 +170,12 @@ export { default as UpdateUserDialog } from './UpdateUserDialog'; export * from './UpdateUserForm'; export { default as UpdateUserForm } from './UpdateUserForm'; +export * from './UpsertSAMLProviderDialog'; +export { default as UpsertSAMLProviderDialog } from './UpsertSAMLProviderDialog'; + +export * from './UpsertSAMLProviderForm'; +export { default as UpsertSAMLProviderForm } from './UpsertSAMLProviderForm'; + export * from './UserTokenManagementDialog'; export { default as UserTokenManagementDialog } from './UserTokenManagementDialog'; diff --git a/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx b/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx index 96868063c7..cfcdd40915 100644 --- a/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx +++ b/packages/javascript/bh-shared-ui/src/views/SSOConfiguration/SSOConfiguration.tsx @@ -17,20 +17,19 @@ import { faSearch } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Box, Grid, Paper, TextField, Typography, useTheme } from '@mui/material'; -import { CreateOIDCProviderRequest, SSOProvider } from 'js-client-library'; +import { SSOProvider, UpsertOIDCProviderRequest, UpsertSAMLProviderFormInputs } from 'js-client-library'; import { ChangeEvent, FC, useMemo, useState } from 'react'; import { useMutation, useQuery } from 'react-query'; import { ConfirmationDialog, CreateMenu, - CreateSAMLProviderDialog, - CreateSAMLProviderFormInputs, DocumentationLinks, PageWithTitle, SSOProviderInfoPanel, SSOProviderTable, + UpsertSAMLProviderDialog, } from '../../components'; -import CreateOIDCProviderDialog from '../../components/CreateOIDCProviderDialog'; +import UpsertOIDCProviderDialog from '../../components/UpsertOIDCProviderDialog'; import { useFeatureFlag } from '../../hooks'; import { useNotifications } from '../../providers'; import { SortOrder, apiClient } from '../../utils'; @@ -42,10 +41,10 @@ const SSOConfiguration: FC = () => { const { data: flag } = useFeatureFlag('oidc_support'); const [selectedSSOProviderId, setSelectedSSOProviderId] = useState(); - const [ssoProviderIdToDelete, setSSOProviderIdToDelete] = useState(); + const [ssoProviderIdToDeleteOrUpdate, setSSOProviderIdToDeleteOrUpdate] = useState(); const [dialogOpen, setDialogOpen] = useState<'SAML' | 'OIDC' | 'DELETE' | ''>(''); const [nameFilter, setNameFilter] = useState(''); - const [createProviderError, setCreateProviderError] = useState(''); + const [upsertProviderError, setUpsertProviderError] = useState(''); const [typeSortOrder, setTypeSortOrder] = useState(); const listSSOProvidersQuery = useQuery(['listSSOProviders'], ({ signal }) => @@ -97,6 +96,10 @@ const SSOConfiguration: FC = () => { return listSSOProvidersQuery.data?.find(({ id }) => id === selectedSSOProviderId); }, [selectedSSOProviderId, listSSOProvidersQuery.data]); + const selectedSSOProviderToUpdate = useMemo(() => { + return listSSOProvidersQuery.data?.find(({ id }) => id === ssoProviderIdToDeleteOrUpdate); + }, [ssoProviderIdToDeleteOrUpdate, listSSOProvidersQuery.data]); + /* Event Handlers */ const openSAMLProviderDialog = () => { @@ -112,24 +115,39 @@ const SSOConfiguration: FC = () => { }; const closeDialog = () => { + setUpsertProviderError(''); setDialogOpen(''); - setCreateProviderError(''); + setTimeout(() => setSSOProviderIdToDeleteOrUpdate(undefined), 500); }; const onClickSSOProvider = (ssoProviderId: SSOProvider['id']) => { setSelectedSSOProviderId(ssoProviderId); }; - const onSelectDeleteSSOProvider = (ssoProviderId: SSOProvider['id']) => { - setSSOProviderIdToDelete(ssoProviderId); - openDeleteProviderDialog(); + const onSelectDeleteOrUpdateSSOProvider = (action: 'DELETE' | 'UPDATE') => (ssoProvider: SSOProvider) => { + setSSOProviderIdToDeleteOrUpdate(ssoProvider.id); + switch (action) { + case 'DELETE': + openDeleteProviderDialog(); + break; + case 'UPDATE': + switch (ssoProvider.type) { + case 'SAML': + openSAMLProviderDialog(); + break; + case 'OIDC': + openOIDCProviderDialog(); + break; + } + break; + } }; const onDeleteSSOProvider = async (response: boolean) => { let errored = false; - if (response && ssoProviderIdToDelete) { + if (response && ssoProviderIdToDeleteOrUpdate) { try { - await deleteSSOProviderMutation.mutateAsync(ssoProviderIdToDelete); + await deleteSSOProviderMutation.mutateAsync(ssoProviderIdToDeleteOrUpdate); } catch (err: any) { if (err?.response?.status !== 404) { errored = true; @@ -152,27 +170,48 @@ const SSOConfiguration: FC = () => { } }; - const createSAMLProvider = async (samlProvider: CreateSAMLProviderFormInputs) => { - setCreateProviderError(''); + const upsertSAMLProvider = async (samlProvider: UpsertSAMLProviderFormInputs) => { + setUpsertProviderError(''); try { - await apiClient.createSAMLProviderFromFile({ ...samlProvider, metadata: samlProvider.metadata[0] }); + const payload = { name: samlProvider.name, metadata: samlProvider.metadata && samlProvider.metadata[0] }; + if (ssoProviderIdToDeleteOrUpdate) { + await apiClient.updateSAMLProviderFromFile(ssoProviderIdToDeleteOrUpdate, payload); + } else { + if (payload.name && payload.metadata) { + await apiClient.createSAMLProviderFromFile({ name: payload.name, metadata: payload.metadata }); + } + } listSSOProvidersQuery.refetch(); closeDialog(); } catch (error) { console.error(error); - setCreateProviderError('Unable to create new SAML Provider configuration. Please try again.'); + setUpsertProviderError( + `Unable to ${ssoProviderIdToDeleteOrUpdate ? 'update' : 'create new'} SAML Provider configuration. Please try again.` + ); } }; - const createOIDCProvider = async (oidcProvider: CreateOIDCProviderRequest) => { - setCreateProviderError(''); + const upsertOIDCProvider = async (oidcProvider: UpsertOIDCProviderRequest) => { + setUpsertProviderError(''); try { - await apiClient.createOIDCProvider(oidcProvider); + if (ssoProviderIdToDeleteOrUpdate) { + await apiClient.updateOIDCProvider(ssoProviderIdToDeleteOrUpdate, oidcProvider); + } else { + if (oidcProvider.name && oidcProvider.client_id && oidcProvider.issuer) { + await apiClient.createOIDCProvider({ + name: oidcProvider.name, + client_id: oidcProvider.client_id, + issuer: oidcProvider.issuer, + }); + } + } listSSOProvidersQuery.refetch(); closeDialog(); } catch (error) { console.error(error); - setCreateProviderError('Unable to create new OIDC Provider configuration. Please try again.'); + setUpsertProviderError( + `Unable to ${ssoProviderIdToDeleteOrUpdate ? 'update' : 'create new'} OIDC Provider configuration. Please try again.` + ); } }; @@ -235,7 +274,8 @@ const SSOConfiguration: FC = () => { ssoProviders={ssoProviders} loading={listSSOProvidersQuery.isLoading} onClickSSOProvider={onClickSSOProvider} - onDeleteSSOProvider={onSelectDeleteSSOProvider} + onDeleteSSOProvider={onSelectDeleteOrUpdateSSOProvider('DELETE')} + onUpdateSSOProvider={onSelectDeleteOrUpdateSSOProvider('UPDATE')} typeSortOrder={typeSortOrder} onToggleTypeSortOrder={toggleTypeSortOrder} /> @@ -248,17 +288,19 @@ const SSOConfiguration: FC = () => { )} - - this.baseClient.get(`/api/v2/saml/providers/${samlProviderId}`, options); - createSAMLProvider = ( - data: { - name: string; - displayName: string; - signingCertificate: string; - issuerUri: string; - singleSignOnUri: string; - principalAttributeMappings: string[]; - }, - options?: types.RequestOptions - ) => - this.baseClient.post( - `/api/v2/saml`, - { - name: data.name, - display_name: data.displayName, - signing_certificate: data.signingCertificate, - issuer_uri: data.issuerUri, - single_signon_uri: data.singleSignOnUri, - principal_attribute_mappings: data.principalAttributeMappings, - }, - options - ); - createSAMLProviderFromFile = (data: { name: string; metadata: File }, options?: types.RequestOptions) => { const formData = new FormData(); formData.append('name', data.name); @@ -673,6 +649,21 @@ class BHEAPIClient { return this.baseClient.post(`/api/v2/saml/providers`, formData, options); }; + updateSAMLProviderFromFile = ( + ssoProviderId: types.SSOProvider['id'], + data: { name?: string; metadata?: File }, + options?: types.RequestOptions + ) => { + const formData = new FormData(); + if (data.name) { + formData.append('name', data.name); + } + if (data.metadata) { + formData.append('metadata', data.metadata); + } + return this.baseClient.patch(`/api/v2/sso-providers/${ssoProviderId}`, formData, options); + }; + validateSAMLProvider = ( data: { name: string; @@ -703,6 +694,9 @@ class BHEAPIClient { createOIDCProvider = (oidcProvider: types.CreateOIDCProviderRequest) => this.baseClient.post(`/api/v2/sso-providers/oidc`, oidcProvider); + updateOIDCProvider = (ssoProviderId: types.SSOProvider['id'], oidcProvider: types.UpdateOIDCProviderRequest) => + this.baseClient.patch(`/api/v2/sso-providers/${ssoProviderId}`, oidcProvider); + listSSOProviders = (options?: types.RequestOptions) => this.baseClient.get(`/api/v2/sso-providers`, options); diff --git a/packages/javascript/js-client-library/src/types.ts b/packages/javascript/js-client-library/src/types.ts index 9b851c7e43..470020a859 100644 --- a/packages/javascript/js-client-library/src/types.ts +++ b/packages/javascript/js-client-library/src/types.ts @@ -149,11 +149,20 @@ export interface PutUserAuthSecretRequest { needsPasswordReset: boolean; } +export interface CreateSAMLProviderFormInputs { + name: string; + metadata: FileList; +} +export type UpdateSAMLProviderFormInputs = Partial; +export type UpsertSAMLProviderFormInputs = CreateSAMLProviderFormInputs | UpdateSAMLProviderFormInputs; + export interface CreateOIDCProviderRequest { name: string; client_id: string; issuer: string; } +export type UpdateOIDCProviderRequest = Partial; +export type UpsertOIDCProviderRequest = CreateOIDCProviderRequest | UpdateOIDCProviderRequest; export interface SAMLProviderInfo extends Serial { name: string; From e8ea43f9f42ff20d8fd56217eb2833282c65edf0 Mon Sep 17 00:00:00 2001 From: mistahj67 <26472282+mistahj67@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:51:21 -0700 Subject: [PATCH 17/18] Bed-5010 feat: Add ability to download SAML signing certificate (#981) --- cmd/api/src/api/registration/v2.go | 4 +- cmd/api/src/api/v2/auth/saml.go | 23 +++- packages/go/crypto/tls.go | 18 +-- packages/go/openapi/doc/openapi.json | 62 ++++++++++ packages/go/openapi/src/openapi.yaml | 2 + ....sso-providers.id.signing-certificate.yaml | 55 +++++++++ .../SSOProviderInfoPanel.tsx | 116 ++++++++++++------ .../js-client-library/src/client.ts | 3 + 8 files changed, 236 insertions(+), 47 deletions(-) create mode 100644 packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index 440745a73d..46a6181a74 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -60,9 +60,11 @@ func registerV2Auth(resources v2.Resources, routerInst *router.Router, permissio routerInst.POST("/api/v2/sso-providers/oidc", managementResource.CreateOIDCProvider).CheckFeatureFlag(resources.DB, appcfg.FeatureOIDCSupport).RequirePermissions(permissions.AuthManageProviders), routerInst.DELETE(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.DeleteSSOProvider).RequirePermissions(permissions.AuthManageProviders), routerInst.PATCH(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.UpdateSSOProvider).RequirePermissions(permissions.AuthManageProviders), + routerInst.GET(fmt.Sprintf("/api/v2/sso-providers/{%s}/signing-certificate", api.URIPathVariableSSOProviderID), managementResource.ServeSigningCertificate).RequirePermissions(permissions.AuthManageProviders), + routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/login", api.URIPathVariableSSOProviderSlug), managementResource.SSOLoginHandler), - routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/metadata", api.URIPathVariableSSOProviderSlug), managementResource.ServeMetadata), routerInst.PathPrefix(fmt.Sprintf("/api/v2/sso/{%s}/callback", api.URIPathVariableSSOProviderSlug), http.HandlerFunc(managementResource.SSOCallbackHandler)), + routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/metadata", api.URIPathVariableSSOProviderSlug), managementResource.ServeMetadata), // Permissions routerInst.GET("/api/v2/permissions", managementResource.ListPermissions).RequirePermissions(permissions.AuthManageSelf), diff --git a/cmd/api/src/api/v2/auth/saml.go b/cmd/api/src/api/v2/auth/saml.go index 89ac2c8800..41827e3f6c 100644 --- a/cmd/api/src/api/v2/auth/saml.go +++ b/cmd/api/src/api/v2/auth/saml.go @@ -28,6 +28,7 @@ import ( "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" "github.com/gorilla/mux" + "github.com/specterops/bloodhound/crypto" "github.com/specterops/bloodhound/headers" "github.com/specterops/bloodhound/log" "github.com/specterops/bloodhound/mediatypes" @@ -255,7 +256,7 @@ func (s ManagementResource) UpdateSAMLProviderRequest(response http.ResponseWrit } } -// Preserve old metadata endpoint +// Preserve old metadata endpoint for saml providers func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request *http.Request) { ssoProviderSlug := mux.Vars(request)[api.URIPathVariableSSOProviderSlug] @@ -266,6 +267,7 @@ func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request } else if serviceProvider, err := auth.NewServiceProvider(*ctx.Get(request.Context()).Host, s.config, *ssoProvider.SAMLProvider); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, err.Error(), request), response) } else { + // Note: This is the samlsp metadata tied to authenticate flow and will not be the same as the XML metadata used to import the SAML provider initially if content, err := xml.MarshalIndent(serviceProvider.Metadata(), "", " "); err != nil { log.Errorf("[SAML] XML marshalling failure during service provider encoding for %s: %v", ssoProvider.SAMLProvider.IssuerURI, err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) @@ -278,6 +280,25 @@ func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request } } +// Provide the saml provider certifcate +func (s ManagementResource) ServeSigningCertificate(response http.ResponseWriter, request *http.Request) { + rawProviderID := mux.Vars(request)[api.URIPathVariableSSOProviderID] + + if ssoProviderID, err := strconv.ParseInt(rawProviderID, 10, 32); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response) + } else if ssoProvider, err := s.db.GetSSOProviderById(request.Context(), int32(ssoProviderID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else if ssoProvider.SAMLProvider == nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response) + } else { + // Note this is the public cert not necessarily the IDP cert + response.Header().Set(headers.ContentDisposition.String(), fmt.Sprintf("attachment; filename=\"%s-signing-certificate.pem\"", ssoProvider.Slug)) + if _, err := response.Write([]byte(crypto.FormatCert(s.config.SAML.ServiceProviderCertificate))); err != nil { + log.Errorf("[SAML] Failed to write response for serving signing certificate: %v", err) + } + } +} + // HandleStartAuthFlow is called to start the SAML authentication process. func (s ManagementResource) SAMLLoginHandler(response http.ResponseWriter, request *http.Request, ssoProvider model.SSOProvider) { if ssoProvider.SAMLProvider == nil { diff --git a/packages/go/crypto/tls.go b/packages/go/crypto/tls.go index e0f8ab0ebc..9ee91b4c51 100644 --- a/packages/go/crypto/tls.go +++ b/packages/go/crypto/tls.go @@ -74,17 +74,21 @@ func X509ParseCert(cert string) (*x509.Certificate, error) { } } -func X509ParsePair(cert, key string) (*x509.Certificate, *rsa.PrivateKey, error) { - formattedCert := cert - - if !strings.HasPrefix("-----BEGIN CERTIFICATE-----", formattedCert) { - formattedCert = "-----BEGIN CERTIFICATE-----\n" + formattedCert +func FormatCert(cert string) string { + if !strings.HasPrefix(cert, "-----BEGIN CERTIFICATE-----") { + cert = "-----BEGIN CERTIFICATE-----\n" + cert } - if !strings.HasSuffix("-----END CERTIFICATE----- ", formattedCert) { - formattedCert = formattedCert + "\n-----END CERTIFICATE----- " + if !strings.HasSuffix(cert, "-----END CERTIFICATE-----") { + cert = cert + "\n-----END CERTIFICATE-----" } + return cert +} + +func X509ParsePair(cert, key string) (*x509.Certificate, *rsa.PrivateKey, error) { + formattedCert := FormatCert(cert) + if certBlock, _ := pem.Decode([]byte(formattedCert)); certBlock == nil { return nil, nil, fmt.Errorf("unable to decode cert") } else if cert, err := x509.ParseCertificate(certBlock.Bytes); err != nil { diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index 31b8cb071f..2d18616602 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -801,6 +801,68 @@ } } }, + "/api/v2/sso-providers/{sso_provider_id}/signing-certificate": { + "parameters": [ + { + "$ref": "#/components/parameters/header.prefer" + }, + { + "description": "SSO Provider ID for a SAML provider", + "name": "sso_provider_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "get": { + "operationId": "GetSSOProviderSAMLSigningCertificate", + "summary": "Get SAML Provider Signing Certificate", + "description": "Download the SAML Provider Signing Certificate. Only applies to SAML providers.", + "tags": [ + "Auth", + "Community", + "Enterprise" + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "content-disposition": { + "schema": { + "type": "string" + }, + "description": "Suggested filename of structure \"{saml-slug}-signing-certificate.pem\"" + } + }, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + } + } + }, "/api/v2/permissions": { "parameters": [ { diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml index e2072fe2e5..7a24725dc4 100644 --- a/packages/go/openapi/src/openapi.yaml +++ b/packages/go/openapi/src/openapi.yaml @@ -224,6 +224,8 @@ paths: $ref: './paths/sso.sso-providers.oidc.yaml' /api/v2/sso-providers/{sso_provider_id}: $ref: './paths/sso.sso-providers.id.yaml' + /api/v2/sso-providers/{sso_provider_id}/signing-certificate: + $ref: './paths/sso.sso-providers.id.signing-certificate.yaml' # permissions /api/v2/permissions: diff --git a/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml b/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml new file mode 100644 index 0000000000..468f768d8a --- /dev/null +++ b/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml @@ -0,0 +1,55 @@ +# Copyright 2024 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +parameters: + - $ref: './../parameters/header.prefer.yaml' + - description: SSO Provider ID for a SAML provider + name: sso_provider_id + in: path + required: true + schema: + type: integer + format: int32 +get: + operationId: GetSSOProviderSAMLSigningCertificate + summary: Get SAML Provider Signing Certificate + description: Download the SAML Provider Signing Certificate. Only applies to SAML providers. + tags: + - Auth + - Community + - Enterprise + responses: + '200': + description: OK + headers: + content-disposition: + schema: + type: string + description: Suggested filename of structure "{saml-slug}-signing-certificate.pem" + content: + text/plain: + schema: + type: string + '401': + $ref: './../responses/unauthorized.yaml' + '403': + $ref: './../responses/forbidden.yaml' + '404': + $ref: './../responses/not-found.yaml' + '429': + $ref: './../responses/too-many-requests.yaml' + '500': + $ref: './../responses/internal-server-error.yaml' diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx index 993609b5ad..081c72750d 100644 --- a/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx +++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx @@ -16,9 +16,13 @@ import { Paper, Box, Typography, useTheme } from '@mui/material'; import { FC } from 'react'; +import fileDownload from 'js-file-download'; import { OIDCProviderInfo, SAMLProviderInfo, SSOProvider } from 'js-client-library'; +import { Button } from '@bloodhoundenterprise/doodleui'; import { Field, FieldsContainer, usePaneStyles, useHeaderStyles } from '../../views/Explore'; import LabelWithCopy from '../LabelWithCopy'; +import { apiClient } from '../../utils'; +import { useNotifications } from '../../providers'; const SAMLProviderInfoPanel: FC<{ samlProviderDetails: SAMLProviderInfo; @@ -73,6 +77,7 @@ const SSOProviderInfoPanel: FC<{ const theme = useTheme(); const paneStyles = usePaneStyles(); const headerStyles = useHeaderStyles(); + const { addNotification } = useNotifications(); if (!ssoProvider.type) { return null; @@ -90,48 +95,83 @@ const SSOProviderInfoPanel: FC<{ infoPanel = null; } + const downloadSAMLSigningCertificate = () => { + if (ssoProvider.type.toLowerCase() == 'oidc') { + addNotification('Only SAML providers support signing certificates.', 'errorDownloadSAMLSigningCertificate'); + } else { + apiClient + .getSAMLProviderSigningCertificate(ssoProvider.id) + .then((res) => { + const filename = + res.headers['content-disposition']?.match(/^.*filename="(.*)"$/)?.[1] || + `${ssoProvider.name}-signing-certificate`; + + fileDownload(res.data, filename); + }) + .catch((err) => { + console.error(err); + addNotification( + 'This file could not be downloaded. Please try again.', + 'downloadSAMLSigningCertificate' + ); + }); + } + }; + return ( - - - - - + + + + + + {ssoProvider?.name} + + + div.node:nth-of-type(odd)': { + background: theme.palette.neutral.tertiary, + }, }}> - {ssoProvider?.name} - - - div.node:nth-of-type(odd)': { - background: theme.palette.neutral.tertiary, - }, - }}> - - Provider Information: - - {infoPanel} + + Provider Information: + + {infoPanel} + -
- + + {ssoProvider.type.toLowerCase() === 'saml' && ( + + + + )} + ); }; diff --git a/packages/javascript/js-client-library/src/client.ts b/packages/javascript/js-client-library/src/client.ts index d03f854d1e..22a3f1908a 100644 --- a/packages/javascript/js-client-library/src/client.ts +++ b/packages/javascript/js-client-library/src/client.ts @@ -688,6 +688,9 @@ class BHEAPIClient { options ); + getSAMLProviderSigningCertificate = (ssoProviderId: types.SSOProvider['id'], options?: types.RequestOptions) => + this.baseClient.get(`/api/v2/sso-providers/${ssoProviderId}/signing-certificate`, options); + deleteSSOProvider = (ssoProviderId: types.SSOProvider['id'], options?: types.RequestOptions) => this.baseClient.delete(`/api/v2/sso-providers/${ssoProviderId}`, options); From 00a246f64154538c597c71e83478ac1b3ca160b4 Mon Sep 17 00:00:00 2001 From: Rohan Vazarkar Date: Mon, 2 Dec 2024 15:31:07 -0500 Subject: [PATCH 18/18] chore: update collectors for https://specterops.atlassian.net/browse/BED-5095 (#991) --- dockerfiles/bloodhound.Dockerfile | 2 +- tools/docker-compose/api.Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dockerfiles/bloodhound.Dockerfile b/dockerfiles/bloodhound.Dockerfile index 0ecea08d38..74cdfbca2a 100644 --- a/dockerfiles/bloodhound.Dockerfile +++ b/dockerfiles/bloodhound.Dockerfile @@ -17,7 +17,7 @@ ######## # Global build args ################ -ARG SHARPHOUND_VERSION=v2.5.8 +ARG SHARPHOUND_VERSION=v2.5.9 ARG AZUREHOUND_VERSION=v2.2.1 ######## diff --git a/tools/docker-compose/api.Dockerfile b/tools/docker-compose/api.Dockerfile index ea2466102c..0feda0a638 100644 --- a/tools/docker-compose/api.Dockerfile +++ b/tools/docker-compose/api.Dockerfile @@ -17,7 +17,7 @@ ######## # Global build args ################ -ARG SHARPHOUND_VERSION=v2.5.8 +ARG SHARPHOUND_VERSION=v2.5.9 ARG AZUREHOUND_VERSION=v2.2.1 ########