Add role management

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-11-07 09:20:38 +01:00
parent 696adb7d79
commit 21c4b7cd9d
143 changed files with 5976 additions and 2094 deletions

View File

@@ -17,11 +17,11 @@ package api
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/vektah/gqlparser/v2/ast"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
@@ -92,6 +92,11 @@ var (
ErrMissingAuthzService = errors.New("server configuration requires a valid authz.Service instance")
)
// GetConsoleSchema returns the GraphQL schema for the console API
func GetConsoleSchema() *ast.Schema {
return console_v1.GetSchema()
}
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

View File

@@ -17,6 +17,10 @@ resolver:
autobind: []
call_argument_directives_with_null: true
directives:
mustBeAuthorized:
skip_runtime: false
models:
ID:
model:

View File

@@ -31,6 +31,7 @@ import (
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/httpserver"
@@ -64,6 +65,7 @@ type (
samlSvc *auth.SAMLService
authCfg AuthConfig
customDomainCname string
schema *ast.Schema
}
ctxKey struct{ name string }
@@ -307,21 +309,32 @@ func NewMux(
return r
}
// GetSchema returns the parsed GraphQL schema for the console API
// This is used by other services like authz to extract permissions from @mustBeAuthorized directives
func GetSchema() *ast.Schema {
execSchema := schema.NewExecutableSchema(schema.Config{})
return execSchema.Schema()
}
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, samlSvc *auth.SAMLService, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
var mb int64 = 1 << 20
es := schema.NewExecutableSchema(
schema.Config{
Resolvers: &Resolver{
proboSvc: proboSvc,
authSvc: authSvc,
authzSvc: authzSvc,
samlSvc: samlSvc,
authCfg: authCfg,
customDomainCname: customDomainCname,
},
// Parse the schema first to make it available to resolvers
execSchema := schema.NewExecutableSchema(schema.Config{})
cfg := schema.Config{
Resolvers: &Resolver{
proboSvc: proboSvc,
authSvc: authSvc,
authzSvc: authzSvc,
samlSvc: samlSvc,
authCfg: authCfg,
customDomainCname: customDomainCname,
schema: execSchema.Schema(),
},
)
}
es := schema.NewExecutableSchema(cfg)
srv := handler.New(es)
srv.AddTransport(transport.POST{})
srv.AddTransport(
@@ -504,3 +517,14 @@ func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
panic(&authz.TenantAccessError{Message: "tenant not found"})
}
}
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
user := UserFromContext(ctx)
apiKey := UserAPIKeyFromContext(ctx)
authzSvc := r.AuthzService(ctx, entityID.TenantID())
err := authzSvc.Authorize(ctx, user, apiKey, entityID, action)
if err != nil {
panic(err)
}
}

View File

@@ -33,6 +33,14 @@ type PageInfo {
endCursor: CursorKey
}
# Roles
enum Role {
OWNER
ADMIN
VIEWER
FULL
}
# Enums
enum OrderDirection
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
@@ -83,13 +91,17 @@ enum InvitationStatus
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusExpired")
}
enum Role @goModel(model: "go.probo.inc/probo/pkg/coredata.Role") {
OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.RoleOwner")
ADMIN @goEnum(value: "go.probo.inc/probo/pkg/coredata.RoleAdmin")
MEMBER @goEnum(value: "go.probo.inc/probo/pkg/coredata.RoleMember")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.RoleViewer")
enum MembershipRole @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") {
OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner")
ADMIN @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAdmin")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleViewer")
}
enum APIRole @goModel(model: "go.probo.inc/probo/pkg/coredata.APIRole") {
FULL @goEnum(value: "go.probo.inc/probo/pkg/coredata.APIRoleFull")
}
enum DocumentStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
@@ -1662,7 +1674,7 @@ type Membership implements Node {
id: ID!
userID: ID!
organizationID: ID!
role: Role!
role: MembershipRole!
fullName: String!
emailAddress: String!
authMethod: UserAuthMethod! @goField(forceResolver: true)
@@ -1674,7 +1686,7 @@ type Invitation implements Node {
id: ID!
email: String!
fullName: String!
role: Role!
role: MembershipRole!
status: InvitationStatus!
expiresAt: Datetime!
acceptedAt: Datetime
@@ -1731,7 +1743,7 @@ type Vendor implements Node {
): VendorComplianceReportConnection! @goField(forceResolver: true)
businessAssociateAgreement: VendorBusinessAssociateAgreement
@goField(forceResolver: true)
@goField(forceResolver: true)
dataPrivacyAgreement: VendorDataPrivacyAgreement @goField(forceResolver: true)
contacts(
@@ -1855,7 +1867,9 @@ type Framework implements Node {
updatedAt: Datetime!
}
type Control implements Node {
type Control implements Node @goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Control"
) {
id: ID!
sectionTitle: String!
name: String!
@@ -2349,7 +2363,7 @@ type TrustCenterReferenceEdge {
node: TrustCenterReference!
}
type TrustCenterFile implements Node {
type TrustCenterFile {
id: ID!
name: String!
category: String!
@@ -2723,8 +2737,7 @@ type Mutation {
# Organization mutations
createOrganization(
input: CreateOrganizationInput!
): CreateOrganizationPayload!
updateOrganization(
): CreateOrganizationPayload! updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload!
updateOrganizationContext(
@@ -2732,218 +2745,145 @@ type Mutation {
): UpdateOrganizationContextPayload!
deleteOrganizationHorizontalLogo(
input: DeleteOrganizationHorizontalLogoInput!
): DeleteOrganizationHorizontalLogoPayload!
deleteOrganization(
): DeleteOrganizationHorizontalLogoPayload! deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload!
updateTrustCenter(input: UpdateTrustCenterInput!): UpdateTrustCenterPayload!
uploadTrustCenterNDA(
input: UploadTrustCenterNDAInput!
): UploadTrustCenterNDAPayload!
deleteTrustCenterNDA(
input: DeleteTrustCenterNDAInput!
): DeleteTrustCenterNDAPayload!
# Trust Center Access CRUD mutations
createTrustCenterAccess(
input: CreateTrustCenterAccessInput!
): CreateTrustCenterAccessPayload!
updateTrustCenterAccess(
input: UpdateTrustCenterAccessInput!
): UpdateTrustCenterAccessPayload!
deleteTrustCenterAccess(
input: DeleteTrustCenterAccessInput!
): DeleteTrustCenterAccessPayload!
# Trust Center Reference mutations
createTrustCenterReference(
input: CreateTrustCenterReferenceInput!
): CreateTrustCenterReferencePayload!
updateTrustCenterReference(
input: UpdateTrustCenterReferenceInput!
): UpdateTrustCenterReferencePayload!
deleteTrustCenterReference(
input: DeleteTrustCenterReferenceInput!
): DeleteTrustCenterReferencePayload!
# Trust Center File mutations
createTrustCenterFile(
input: CreateTrustCenterFileInput!
): CreateTrustCenterFilePayload!
updateTrustCenterFile(
input: UpdateTrustCenterFileInput!
): UpdateTrustCenterFilePayload!
getTrustCenterFile(
input: GetTrustCenterFileInput!
): GetTrustCenterFilePayload!
deleteTrustCenterFile(
input: DeleteTrustCenterFileInput!
): DeleteTrustCenterFilePayload!
# User mutations
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload! removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload!
# People mutations
createPeople(input: CreatePeopleInput!): CreatePeoplePayload!
updatePeople(input: UpdatePeopleInput!): UpdatePeoplePayload!
deletePeople(input: DeletePeopleInput!): DeletePeoplePayload!
createPeople(input: CreatePeopleInput!): CreatePeoplePayload! updatePeople(input: UpdatePeopleInput!): UpdatePeoplePayload! deletePeople(input: DeletePeopleInput!): DeletePeoplePayload!
# Vendor mutations
createVendor(input: CreateVendorInput!): CreateVendorPayload!
updateVendor(input: UpdateVendorInput!): UpdateVendorPayload!
deleteVendor(input: DeleteVendorInput!): DeleteVendorPayload!
createVendor(input: CreateVendorInput!): CreateVendorPayload! updateVendor(input: UpdateVendorInput!): UpdateVendorPayload! deleteVendor(input: DeleteVendorInput!): DeleteVendorPayload!
# Vendor Contact mutations
createVendorContact(
input: CreateVendorContactInput!
): CreateVendorContactPayload!
updateVendorContact(
): CreateVendorContactPayload! updateVendorContact(
input: UpdateVendorContactInput!
): UpdateVendorContactPayload!
deleteVendorContact(
): UpdateVendorContactPayload! deleteVendorContact(
input: DeleteVendorContactInput!
): DeleteVendorContactPayload!
# Vendor Service mutations
createVendorService(
input: CreateVendorServiceInput!
): CreateVendorServicePayload!
updateVendorService(
): CreateVendorServicePayload! updateVendorService(
input: UpdateVendorServiceInput!
): UpdateVendorServicePayload!
deleteVendorService(
): UpdateVendorServicePayload! deleteVendorService(
input: DeleteVendorServiceInput!
): DeleteVendorServicePayload!
# Framework mutations
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload!
updateFramework(input: UpdateFrameworkInput!): UpdateFrameworkPayload!
importFramework(input: ImportFrameworkInput!): ImportFrameworkPayload!
deleteFramework(input: DeleteFrameworkInput!): DeleteFrameworkPayload!
generateFrameworkStateOfApplicability(
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload! updateFramework(input: UpdateFrameworkInput!): UpdateFrameworkPayload! importFramework(input: ImportFrameworkInput!): ImportFrameworkPayload! deleteFramework(input: DeleteFrameworkInput!): DeleteFrameworkPayload! generateFrameworkStateOfApplicability(
input: GenerateFrameworkStateOfApplicabilityInput!
): GenerateFrameworkStateOfApplicabilityPayload!
exportFramework(input: ExportFrameworkInput!): ExportFrameworkPayload!
): GenerateFrameworkStateOfApplicabilityPayload! exportFramework(input: ExportFrameworkInput!): ExportFrameworkPayload!
# Control mutations
createControl(input: CreateControlInput!): CreateControlPayload!
updateControl(input: UpdateControlInput!): UpdateControlPayload!
deleteControl(input: DeleteControlInput!): DeleteControlPayload!
createControl(input: CreateControlInput!): CreateControlPayload! updateControl(input: UpdateControlInput!): UpdateControlPayload! deleteControl(input: DeleteControlInput!): DeleteControlPayload!
# Measure mutations
createMeasure(input: CreateMeasureInput!): CreateMeasurePayload!
updateMeasure(input: UpdateMeasureInput!): UpdateMeasurePayload!
importMeasure(input: ImportMeasureInput!): ImportMeasurePayload!
deleteMeasure(input: DeleteMeasureInput!): DeleteMeasurePayload!
createMeasure(input: CreateMeasureInput!): CreateMeasurePayload! updateMeasure(input: UpdateMeasureInput!): UpdateMeasurePayload! importMeasure(input: ImportMeasureInput!): ImportMeasurePayload! deleteMeasure(input: DeleteMeasureInput!): DeleteMeasurePayload!
# Control mutations
createControlMeasureMapping(
input: CreateControlMeasureMappingInput!
): CreateControlMeasureMappingPayload!
createControlDocumentMapping(
): CreateControlMeasureMappingPayload! createControlDocumentMapping(
input: CreateControlDocumentMappingInput!
): CreateControlDocumentMappingPayload!
deleteControlMeasureMapping(
): CreateControlDocumentMappingPayload! deleteControlMeasureMapping(
input: DeleteControlMeasureMappingInput!
): DeleteControlMeasureMappingPayload!
deleteControlDocumentMapping(
): DeleteControlMeasureMappingPayload! deleteControlDocumentMapping(
input: DeleteControlDocumentMappingInput!
): DeleteControlDocumentMappingPayload!
createControlAuditMapping(
): DeleteControlDocumentMappingPayload! createControlAuditMapping(
input: CreateControlAuditMappingInput!
): CreateControlAuditMappingPayload!
deleteControlAuditMapping(
): CreateControlAuditMappingPayload! deleteControlAuditMapping(
input: DeleteControlAuditMappingInput!
): DeleteControlAuditMappingPayload!
createControlSnapshotMapping(
): DeleteControlAuditMappingPayload! createControlSnapshotMapping(
input: CreateControlSnapshotMappingInput!
): CreateControlSnapshotMappingPayload!
deleteControlSnapshotMapping(
): CreateControlSnapshotMappingPayload! deleteControlSnapshotMapping(
input: DeleteControlSnapshotMappingInput!
): DeleteControlSnapshotMappingPayload!
# Task mutations
createTask(input: CreateTaskInput!): CreateTaskPayload!
updateTask(input: UpdateTaskInput!): UpdateTaskPayload!
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
assignTask(input: AssignTaskInput!): AssignTaskPayload!
unassignTask(input: UnassignTaskInput!): UnassignTaskPayload!
createTask(input: CreateTaskInput!): CreateTaskPayload! updateTask(input: UpdateTaskInput!): UpdateTaskPayload! deleteTask(input: DeleteTaskInput!): DeleteTaskPayload! assignTask(input: AssignTaskInput!): AssignTaskPayload! unassignTask(input: UnassignTaskInput!): UnassignTaskPayload!
# Risk mutations
createRisk(input: CreateRiskInput!): CreateRiskPayload!
updateRisk(input: UpdateRiskInput!): UpdateRiskPayload!
deleteRisk(input: DeleteRiskInput!): DeleteRiskPayload!
createRiskMeasureMapping(
createRisk(input: CreateRiskInput!): CreateRiskPayload! updateRisk(input: UpdateRiskInput!): UpdateRiskPayload! deleteRisk(input: DeleteRiskInput!): DeleteRiskPayload! createRiskMeasureMapping(
input: CreateRiskMeasureMappingInput!
): CreateRiskMeasureMappingPayload!
deleteRiskMeasureMapping(
): CreateRiskMeasureMappingPayload! deleteRiskMeasureMapping(
input: DeleteRiskMeasureMappingInput!
): DeleteRiskMeasureMappingPayload!
createRiskDocumentMapping(
input: CreateRiskDocumentMappingInput!
): CreateRiskDocumentMappingPayload!
deleteRiskDocumentMapping(
): CreateRiskDocumentMappingPayload! deleteRiskDocumentMapping(
input: DeleteRiskDocumentMappingInput!
): DeleteRiskDocumentMappingPayload!
createRiskObligationMapping(
input: CreateRiskObligationMappingInput!
): CreateRiskObligationMappingPayload!
deleteRiskObligationMapping(
): CreateRiskObligationMappingPayload! deleteRiskObligationMapping(
input: DeleteRiskObligationMappingInput!
): DeleteRiskObligationMappingPayload!
# Evidence mutations
deleteEvidence(input: DeleteEvidenceInput!): DeleteEvidencePayload!
uploadMeasureEvidence(
deleteEvidence(input: DeleteEvidenceInput!): DeleteEvidencePayload! uploadMeasureEvidence(
input: UploadMeasureEvidenceInput!
): UploadMeasureEvidencePayload!
# Vendor Compliance Report mutations
uploadVendorComplianceReport(
input: UploadVendorComplianceReportInput!
): UploadVendorComplianceReportPayload!
deleteVendorComplianceReport(
): UploadVendorComplianceReportPayload! deleteVendorComplianceReport(
input: DeleteVendorComplianceReportInput!
): DeleteVendorComplianceReportPayload!
# Vendor Business Associate Agreement mutations
uploadVendorBusinessAssociateAgreement(
input: UploadVendorBusinessAssociateAgreementInput!
): UploadVendorBusinessAssociateAgreementPayload!
updateVendorBusinessAssociateAgreement(
): UploadVendorBusinessAssociateAgreementPayload! updateVendorBusinessAssociateAgreement(
input: UpdateVendorBusinessAssociateAgreementInput!
): UpdateVendorBusinessAssociateAgreementPayload!
deleteVendorBusinessAssociateAgreement(
): UpdateVendorBusinessAssociateAgreementPayload! deleteVendorBusinessAssociateAgreement(
input: DeleteVendorBusinessAssociateAgreementInput!
): DeleteVendorBusinessAssociateAgreementPayload!
# Vendor Data Privacy Agreement mutations
uploadVendorDataPrivacyAgreement(
input: UploadVendorDataPrivacyAgreementInput!
): UploadVendorDataPrivacyAgreementPayload!
updateVendorDataPrivacyAgreement(
): UploadVendorDataPrivacyAgreementPayload! updateVendorDataPrivacyAgreement(
input: UpdateVendorDataPrivacyAgreementInput!
): UpdateVendorDataPrivacyAgreementPayload!
deleteVendorDataPrivacyAgreement(
): UpdateVendorDataPrivacyAgreementPayload! deleteVendorDataPrivacyAgreement(
input: DeleteVendorDataPrivacyAgreementInput!
): DeleteVendorDataPrivacyAgreementPayload!
# Document mutations
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
@@ -2954,134 +2894,85 @@ type Mutation {
deleteMeeting(input: DeleteMeetingInput!): DeleteMeetingPayload!
publishDocumentVersion(
input: PublishDocumentVersionInput!
): PublishDocumentVersionPayload!
bulkPublishDocumentVersions(
): PublishDocumentVersionPayload! bulkPublishDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
bulkDeleteDocuments(
): BulkPublishDocumentVersionsPayload! bulkDeleteDocuments(
input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload!
bulkExportDocuments(
): BulkDeleteDocumentsPayload! bulkExportDocuments(
input: BulkExportDocumentsInput!
): BulkExportDocumentsPayload!
generateDocumentChangelog(
): BulkExportDocumentsPayload! generateDocumentChangelog(
input: GenerateDocumentChangelogInput!
): GenerateDocumentChangelogPayload!
createDraftDocumentVersion(
): GenerateDocumentChangelogPayload! createDraftDocumentVersion(
input: CreateDraftDocumentVersionInput!
): CreateDraftDocumentVersionPayload!
deleteDraftDocumentVersion(
): CreateDraftDocumentVersionPayload! deleteDraftDocumentVersion(
input: DeleteDraftDocumentVersionInput!
): DeleteDraftDocumentVersionPayload!
updateDocumentVersion(
): DeleteDraftDocumentVersionPayload! updateDocumentVersion(
input: UpdateDocumentVersionInput!
): UpdateDocumentVersionPayload!
requestSignature(input: RequestSignatureInput!): RequestSignaturePayload!
bulkRequestSignatures(
): UpdateDocumentVersionPayload! requestSignature(input: RequestSignatureInput!): RequestSignaturePayload! bulkRequestSignatures(
input: BulkRequestSignaturesInput!
): BulkRequestSignaturesPayload!
sendSigningNotifications(
): BulkRequestSignaturesPayload! sendSigningNotifications(
input: SendSigningNotificationsInput!
): SendSigningNotificationsPayload!
cancelSignatureRequest(
): SendSigningNotificationsPayload! cancelSignatureRequest(
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
exportDocumentVersionPDF(
): CancelSignatureRequestPayload! exportDocumentVersionPDF(
input: ExportDocumentVersionPDFInput!
): ExportDocumentVersionPDFPayload!
createVendorRiskAssessment(
input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload!
assessVendor(input: AssessVendorInput!): AssessVendorPayload!
createAsset(input: CreateAssetInput!): CreateAssetPayload!
updateAsset(input: UpdateAssetInput!): UpdateAssetPayload!
deleteAsset(input: DeleteAssetInput!): DeleteAssetPayload!
createDatum(input: CreateDatumInput!): CreateDatumPayload!
updateDatum(input: UpdateDatumInput!): UpdateDatumPayload!
deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
createAudit(input: CreateAuditInput!): CreateAuditPayload!
updateAudit(input: UpdateAuditInput!): UpdateAuditPayload!
deleteAudit(input: DeleteAuditInput!): DeleteAuditPayload!
uploadAuditReport(input: UploadAuditReportInput!): UploadAuditReportPayload!
deleteAuditReport(input: DeleteAuditReportInput!): DeleteAuditReportPayload!
createAsset(input: CreateAssetInput!): CreateAssetPayload! updateAsset(input: UpdateAssetInput!): UpdateAssetPayload! deleteAsset(input: DeleteAssetInput!): DeleteAssetPayload!
createDatum(input: CreateDatumInput!): CreateDatumPayload! updateDatum(input: UpdateDatumInput!): UpdateDatumPayload! deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
createAudit(input: CreateAuditInput!): CreateAuditPayload! updateAudit(input: UpdateAuditInput!): UpdateAuditPayload! deleteAudit(input: DeleteAuditInput!): DeleteAuditPayload! uploadAuditReport(input: UploadAuditReportInput!): UploadAuditReportPayload! deleteAuditReport(input: DeleteAuditReportInput!): DeleteAuditReportPayload!
# Nonconformity mutations
createNonconformity(
input: CreateNonconformityInput!
): CreateNonconformityPayload!
updateNonconformity(
): CreateNonconformityPayload! updateNonconformity(
input: UpdateNonconformityInput!
): UpdateNonconformityPayload!
deleteNonconformity(
): UpdateNonconformityPayload! deleteNonconformity(
input: DeleteNonconformityInput!
): DeleteNonconformityPayload!
# Obligation mutations
createObligation(input: CreateObligationInput!): CreateObligationPayload!
updateObligation(input: UpdateObligationInput!): UpdateObligationPayload!
deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
createObligation(input: CreateObligationInput!): CreateObligationPayload! updateObligation(input: UpdateObligationInput!): UpdateObligationPayload! deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
# Continual Improvement mutations
createContinualImprovement(
input: CreateContinualImprovementInput!
): CreateContinualImprovementPayload!
updateContinualImprovement(
): CreateContinualImprovementPayload! updateContinualImprovement(
input: UpdateContinualImprovementInput!
): UpdateContinualImprovementPayload!
deleteContinualImprovement(
): UpdateContinualImprovementPayload! deleteContinualImprovement(
input: DeleteContinualImprovementInput!
): DeleteContinualImprovementPayload!
# Processing Activity mutations
createProcessingActivity(
input: CreateProcessingActivityInput!
): CreateProcessingActivityPayload!
updateProcessingActivity(
): CreateProcessingActivityPayload! updateProcessingActivity(
input: UpdateProcessingActivityInput!
): UpdateProcessingActivityPayload!
deleteProcessingActivity(
): UpdateProcessingActivityPayload! deleteProcessingActivity(
input: DeleteProcessingActivityInput!
): DeleteProcessingActivityPayload!
# Snapshot mutations
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload!
deleteSnapshot(input: DeleteSnapshotInput!): DeleteSnapshotPayload!
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload! deleteSnapshot(input: DeleteSnapshotInput!): DeleteSnapshotPayload!
# Custom Domain mutations
createCustomDomain(
input: CreateCustomDomainInput!
): CreateCustomDomainPayload!
deleteCustomDomain(
): CreateCustomDomainPayload! deleteCustomDomain(
input: DeleteCustomDomainInput!
): DeleteCustomDomainPayload!
# SAML Configuration mutations (OWNER/ADMIN only)
# Step 1: Initiate domain verification (creates SAML config with unverified domain)
initiateDomainVerification(
input: InitiateDomainVerificationInput!
): InitiateDomainVerificationPayload!
# Step 2: Verify domain ownership via DNS TXT record
verifyDomain(input: VerifyDomainInput!): VerifyDomainPayload!
# Step 3: Configure SAML (only allowed after domain is verified)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload!
updateSAMLConfiguration(
): CreateSAMLConfigurationPayload! updateSAMLConfiguration(
input: UpdateSAMLConfigurationInput!
): UpdateSAMLConfigurationPayload!
deleteSAMLConfiguration(
): UpdateSAMLConfigurationPayload! deleteSAMLConfiguration(
input: DeleteSAMLConfigurationInput!
): DeleteSAMLConfigurationPayload!
enableSAML(input: EnableSAMLInput!): EnableSAMLPayload!
disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!
}
): DeleteSAMLConfigurationPayload! enableSAML(input: EnableSAMLInput!): EnableSAMLPayload! disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!}
# Input Types
input GenerateFrameworkStateOfApplicabilityInput {
@@ -3629,6 +3520,7 @@ input InviteUserInput {
organizationId: ID!
email: String!
fullName: String!
role: MembershipRole!
createPeople: Boolean!
}
@@ -3645,6 +3537,12 @@ input RemoveMemberInput {
memberId: ID!
}
input UpdateMembershipInput {
organizationId: ID!
memberId: ID!
role: MembershipRole!
}
input CreateControlInput {
frameworkId: ID!
sectionTitle: String!
@@ -4214,6 +4112,10 @@ type RemoveMemberPayload {
deletedMemberId: ID!
}
type UpdateMembershipPayload {
membership: Membership!
}
input VendorRiskAssessmentOrder {
field: VendorRiskAssessmentOrderField!
direction: OrderDirection!
@@ -4264,7 +4166,7 @@ type DeleteMeasurePayload {
deletedMeasureId: ID!
}
type DocumentVersion implements Node {
type DocumentVersion implements Node @goModel(model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersion") {
id: ID!
document: Document! @goField(forceResolver: true)
status: DocumentStatus!
@@ -4549,7 +4451,7 @@ type DeleteAssetPayload {
deletedAssetId: ID!
}
type Datum implements Node {
type Datum implements Node @goModel(model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Datum") {
id: ID!
snapshotId: ID
name: String!

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,33 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type Control struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
SectionTitle string `json:"sectionTitle"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status coredata.ControlStatus `json:"status"`
ExclusionJustification *string `json:"exclusionJustification,omitempty"`
Framework *Framework `json:"framework"`
Measures *MeasureConnection `json:"measures"`
Documents *DocumentConnection `json:"documents"`
Audits *AuditConnection `json:"audits"`
Snapshots *SnapshotConnection `json:"snapshots"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Control) IsNode() {}
func (c Control) GetID() gid.GID { return c.ID }
type (
ControlOrderBy OrderBy[coredata.ControlOrderField]
@@ -36,42 +44,42 @@ type (
func NewControlConnection(
p *page.Page[*coredata.Control, coredata.ControlOrderField],
parentType any,
resolver any,
parentID gid.GID,
filters *coredata.ControlFilter,
filter *coredata.ControlFilter,
) *ControlConnection {
var edges = make([]*ControlEdge, len(p.Data))
for i := range edges {
edges[i] = NewControlEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges := make([]*ControlEdge, len(p.Data))
for i, control := range p.Data {
edges[i] = NewControlEdge(control, p.Cursor.OrderBy.Field)
}
return &ControlConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
Resolver: resolver,
ParentID: parentID,
Filters: filters,
Filters: filter,
}
}
func NewControlEdge(c *coredata.Control, orderBy coredata.ControlOrderField) *ControlEdge {
return &ControlEdge{
Cursor: c.CursorKey(orderBy),
Node: NewControl(c),
}
}
func NewControl(c *coredata.Control) *Control {
func NewControl(control *coredata.Control) *Control {
return &Control{
ID: c.ID,
SectionTitle: c.SectionTitle,
Name: c.Name,
Description: c.Description,
Status: c.Status,
ExclusionJustification: c.ExclusionJustification,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
ID: control.ID,
OrganizationID: control.OrganizationID,
SectionTitle: control.SectionTitle,
Name: control.Name,
Description: control.Description,
Status: control.Status,
ExclusionJustification: control.ExclusionJustification,
CreatedAt: control.CreatedAt,
UpdatedAt: control.UpdatedAt,
}
}
func NewControlEdge(control *coredata.Control, orderField coredata.ControlOrderField) *ControlEdge {
return &ControlEdge{
Node: NewControl(control),
Cursor: control.CursorKey(orderField),
}
}

View File

@@ -15,11 +15,29 @@
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type Datum struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
Name string `json:"name"`
DataClassification coredata.DataClassification `json:"dataClassification"`
Owner *People `json:"owner"`
Vendors *VendorConnection `json:"vendors"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Datum) IsNode() {}
func (this Datum) GetID() gid.GID { return this.ID }
type (
DatumOrderBy OrderBy[coredata.DatumOrderField]
@@ -58,12 +76,12 @@ func NewDataConnection(
func NewDatum(d *coredata.Datum) *Datum {
return &Datum{
ID: d.ID,
OrganizationID: d.OrganizationID,
Name: d.Name,
SnapshotID: d.SnapshotID,
DataClassification: d.DataClassification,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Organization: &Organization{ID: d.OrganizationID},
}
}

View File

@@ -15,14 +15,40 @@
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DocumentVersion struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
Document *Document `json:"document"`
Status coredata.DocumentStatus `json:"status"`
Version int `json:"version"`
Content string `json:"content"`
Changelog string `json:"changelog"`
Title string `json:"title"`
Classification coredata.DocumentClassification `json:"classification"`
Owner *People `json:"owner"`
Signatures *DocumentVersionSignatureConnection `json:"signatures"`
PublishedAt *time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
DocumentVersionOrderBy OrderBy[coredata.DocumentVersionOrderField]
)
func (DocumentVersion) IsNode() {}
func (d DocumentVersion) GetID() gid.GID {
return d.ID
}
func NewDocumentVersionConnection(page *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) *DocumentVersionConnection {
edges := make([]*DocumentVersionEdge, len(page.Data))
for i, documentVersion := range page.Data {
@@ -55,6 +81,7 @@ func NewDocumentVersionEdge(documentVersion *coredata.DocumentVersion, orderBy c
func NewDocumentVersion(documentVersion *coredata.DocumentVersion) *DocumentVersion {
return &DocumentVersion{
ID: documentVersion.ID,
OrganizationID: documentVersion.OrganizationID,
Version: documentVersion.VersionNumber,
Title: documentVersion.Title,
Content: documentVersion.Content,

View File

@@ -59,6 +59,7 @@ func NewProcessingActivity(par *coredata.ProcessingActivity) *ProcessingActivity
return &ProcessingActivity{
ID: par.ID,
SnapshotID: par.SnapshotID,
SourceID: par.SourceID,
Name: par.Name,
Purpose: par.Purpose,
DataSubjectCategory: par.DataSubjectCategory,

View File

@@ -30,6 +30,7 @@ func NewTrustCenterAccess(tca *coredata.TrustCenterAccess) *TrustCenterAccess {
HasAcceptedNonDisclosureAgreement: tca.HasAcceptedNonDisclosureAgreement,
CreatedAt: tca.CreatedAt,
UpdatedAt: tca.UpdatedAt,
LastTokenExpiresAt: tca.LastTokenExpiresAt,
}
}

View File

@@ -36,6 +36,7 @@ type (
TrustCenterDocumentAccess struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
Active bool `json:"active"`
Requested bool `json:"requested"`
CreatedAt time.Time `json:"createdAt"`
@@ -55,6 +56,7 @@ type (
func NewTrustCenterDocumentAccess(tcda *coredata.TrustCenterDocumentAccess) *TrustCenterDocumentAccess {
return &TrustCenterDocumentAccess{
ID: tcda.ID,
OrganizationID: tcda.OrganizationID,
Active: tcda.Active,
Requested: tcda.Requested,
CreatedAt: tcda.CreatedAt,

View File

@@ -3,6 +3,10 @@
package types
import (
"bytes"
"fmt"
"io"
"strconv"
"time"
"github.com/99designs/gqlgen/graphql"
@@ -174,25 +178,6 @@ type ContinualImprovementFilter struct {
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
}
type Control struct {
ID gid.GID `json:"id"`
SectionTitle string `json:"sectionTitle"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status coredata.ControlStatus `json:"status"`
ExclusionJustification *string `json:"exclusionJustification,omitempty"`
Framework *Framework `json:"framework"`
Measures *MeasureConnection `json:"measures"`
Documents *DocumentConnection `json:"documents"`
Audits *AuditConnection `json:"audits"`
Snapshots *SnapshotConnection `json:"snapshots"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Control) IsNode() {}
func (this Control) GetID() gid.GID { return this.ID }
type ControlEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Control `json:"node"`
@@ -685,21 +670,6 @@ type DNSRecordInstruction struct {
Purpose string `json:"purpose"`
}
type Datum struct {
ID gid.GID `json:"id"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
Name string `json:"name"`
DataClassification coredata.DataClassification `json:"dataClassification"`
Owner *People `json:"owner"`
Vendors *VendorConnection `json:"vendors"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Datum) IsNode() {}
func (this Datum) GetID() gid.GID { return this.ID }
type DatumEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Datum `json:"node"`
@@ -1087,25 +1057,6 @@ type DocumentFilter struct {
Query *string `json:"query,omitempty"`
}
type DocumentVersion struct {
ID gid.GID `json:"id"`
Document *Document `json:"document"`
Status coredata.DocumentStatus `json:"status"`
Version int `json:"version"`
Content string `json:"content"`
Changelog string `json:"changelog"`
Title string `json:"title"`
Classification coredata.DocumentClassification `json:"classification"`
Owner *People `json:"owner"`
Signatures *DocumentVersionSignatureConnection `json:"signatures"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (DocumentVersion) IsNode() {}
func (this DocumentVersion) GetID() gid.GID { return this.ID }
type DocumentVersionConnection struct {
Edges []*DocumentVersionEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
@@ -1297,7 +1248,7 @@ type Invitation struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role coredata.Role `json:"role"`
Role coredata.MembershipRole `json:"role"`
Status coredata.InvitationStatus `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
@@ -1323,10 +1274,11 @@ type InvitationOrder struct {
}
type InviteUserInput struct {
OrganizationID gid.GID `json:"organizationId"`
Email string `json:"email"`
FullName string `json:"fullName"`
CreatePeople bool `json:"createPeople"`
OrganizationID gid.GID `json:"organizationId"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role coredata.MembershipRole `json:"role"`
CreatePeople bool `json:"createPeople"`
}
type InviteUserPayload struct {
@@ -1383,7 +1335,7 @@ type Membership struct {
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
OrganizationID gid.GID `json:"organizationID"`
Role coredata.Role `json:"role"`
Role coredata.MembershipRole `json:"role"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
AuthMethod coredata.UserAuthMethod `json:"authMethod"`
@@ -1855,9 +1807,6 @@ type TrustCenterFile struct {
Organization *Organization `json:"organization"`
}
func (TrustCenterFile) IsNode() {}
func (this TrustCenterFile) GetID() gid.GID { return this.ID }
type TrustCenterFileEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *TrustCenterFile `json:"node"`
@@ -2014,6 +1963,16 @@ type UpdateMeetingPayload struct {
Meeting *Meeting `json:"meeting"`
}
type UpdateMembershipInput struct {
OrganizationID gid.GID `json:"organizationId"`
MemberID gid.GID `json:"memberId"`
Role coredata.MembershipRole `json:"role"`
}
type UpdateMembershipPayload struct {
Membership *Membership `json:"membership"`
}
type UpdateNonconformityInput struct {
ID gid.GID `json:"id"`
ReferenceID *string `json:"referenceId,omitempty"`
@@ -2551,3 +2510,62 @@ type Viewer struct {
User *User `json:"user"`
Organizations *OrganizationConnection `json:"organizations"`
}
type Role string
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleViewer Role = "VIEWER"
RoleFull Role = "FULL"
)
var AllRole = []Role{
RoleOwner,
RoleAdmin,
RoleViewer,
RoleFull,
}
func (e Role) IsValid() bool {
switch e {
case RoleOwner, RoleAdmin, RoleViewer, RoleFull:
return true
}
return false
}
func (e Role) String() string {
return string(e)
}
func (e *Role) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = Role(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid Role", str)
}
return nil
}
func (e Role) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *Role) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e Role) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -58,9 +58,9 @@ func NewServer(cfg Config) (*Server, error) {
router.Post("/invitations/accept", AcceptInvitationHandler(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Get("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, ListUserAPIKeysHandler(cfg.Auth, cfg.Authz)))
router.Post("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, CreateUserAPIKeyHandler(cfg.Auth)))
router.Post("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, CreateUserAPIKeyHandler(cfg.Auth, cfg.Authz)))
router.Get("/api-keys/{id}", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, GetUserAPIKeyHandler(cfg.Auth)))
router.Put("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, UpdateUserAPIKeyHandler(cfg.Auth)))
router.Put("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, UpdateUserAPIKeyHandler(cfg.Auth, cfg.Authz)))
router.Delete("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, DeleteUserAPIKeyHandler(cfg.Auth)))
router.Get("/saml/login/{samlConfigID}", SAMLLoginHandler(cfg.SAML, cfg.Auth, cfg.Logger))

View File

@@ -22,6 +22,7 @@ import (
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
@@ -44,7 +45,7 @@ type (
}
)
func CreateUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
func CreateUserAPIKeyHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
@@ -92,6 +93,24 @@ func CreateUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
})
return
}
// Check if user is an OWNER for this organization
tenantAuthzSvc := authzSvc.WithTenant(orgID.TenantID())
role, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "user does not have access to this organization",
})
return
}
if role != coredata.MembershipRoleOwner {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "only owners can create API keys for this organization",
})
return
}
orgInputs[i] = authsvc.UserAPIKeyOrganizationRequest{
OrganizationID: orgID,
Role: coredata.APIRole(org.Role),

View File

@@ -18,11 +18,11 @@ import (
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.gearno.de/kit/httpserver"
)
type (
@@ -106,9 +106,21 @@ func ListOrganizationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service)
user := UserFromContext(ctx)
sess := SessionFromContext(ctx)
organizations, err := authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user: %w", err))
var organizations coredata.Organizations
var err error
roleFilter := r.URL.Query().Get("role")
if roleFilter != "" {
role := coredata.MembershipRole(roleFilter)
organizations, err = authzSvc.GetUserOrganizationsWithRole(ctx, user.ID, role)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user with role: %w", err))
}
} else {
organizations, err = authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user: %w", err))
}
}
orgIDs := make([]gid.GID, len(organizations))

View File

@@ -22,6 +22,7 @@ import (
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
@@ -32,7 +33,7 @@ type UpdateUserAPIKeyRequest struct {
Organizations []UserAPIKeyOrganizationMembershipRequest `json:"organizations"`
}
func UpdateUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
func UpdateUserAPIKeyHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
@@ -100,6 +101,22 @@ func UpdateUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
return
}
// Check if user is an OWNER for this organization
tenantAuthzSvc := authzSvc.WithTenant(orgID.TenantID())
role, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "user does not have access to this organization",
})
return
}
if role != coredata.MembershipRoleOwner {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "only owners can update API keys for this organization",
})
return
}
orgInputs[i] = authsvc.UserAPIKeyOrganizationRequest{
OrganizationID: orgID,
Role: coredata.APIRole(org.Role),

121
pkg/server/authz/authz.go Normal file
View File

@@ -0,0 +1,121 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package authz
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/server/session"
)
type Config struct {
Auth *authsvc.Service
Authz *authz.Service
Logger *log.Logger
CookieName string
CookieSecret string
CookieSecure bool
}
type Server struct {
router *chi.Mux
}
type ctxKey struct{ name string }
var (
userContextKey = &ctxKey{name: "user"}
)
func NewServer(cfg Config) (*Server, error) {
router := chi.NewRouter()
// Apply authentication middleware to all routes
router.Use(requireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Get("/{organizationID}/permissions", PermissionsHandler(cfg.Authz, UserFromContext, cfg.Logger))
return &Server{
router: router,
}, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
// requireAuth is a middleware that requires authentication
func requireAuth(
authService *authsvc.Service,
authzService *authz.Service,
cookieName string,
cookieSecret string,
cookieSecure bool,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: cookieName,
CookieSecret: cookieSecret,
CookieSecure: cookieSecure,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("cannot list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authService, authzService, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
ctx = context.WithValue(ctx, userContextKey, authResult.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package authz
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// PermissionsHandler returns permissions for the current user's role in an organization
// It uses the centralized permissions map
func PermissionsHandler(
authzService *authz.Service,
userFromContext func(ctx context.Context) *coredata.User,
logger *log.Logger,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
orgIDStr := chi.URLParam(r, "organizationID")
if orgIDStr == "" {
http.Error(w, "organizationID parameter required", http.StatusBadRequest)
return
}
orgID, err := gid.ParseGID(orgIDStr)
if err != nil {
http.Error(w, "invalid organizationID", http.StatusBadRequest)
return
}
user := userFromContext(ctx)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tenantAuthzSvc := authzService.WithTenant(orgID.TenantID())
memberRole, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
http.Error(w, fmt.Sprintf("cannot get user role: %v", err), http.StatusInternalServerError)
return
}
userRole := authz.Role(memberRole.String())
permissions := authz.GetPermissionsByRole(userRole)
response := map[string]any{
"permissions": permissions,
"role": memberRole.String(),
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -29,6 +29,15 @@ func Unauthorized() *gqlerror.Error {
}
}
func Forbidden(err error) *gqlerror.Error {
return &gqlerror.Error{
Message: err.Error(),
Extensions: map[string]any{
"code": "FORBIDDEN",
},
}
}
func AuthenticationRequired(details map[string]any) *gqlerror.Error {
extensions := map[string]any{"code": "AUTHENTICATION_REQUIRED"}
maps.Copy(extensions, details)

View File

@@ -77,6 +77,11 @@ func RecoverFunc(ctx context.Context, err any) error {
return Unauthorized()
}
var permissionDeniedErr *authz.PermissionDeniedError
if errTyped, ok := err.(error); ok && errors.As(errTyped, &permissionDeniedErr) {
return Forbidden(permissionDeniedErr)
}
logger := httpserver.LoggerFromContext(ctx)
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))

View File

@@ -35,6 +35,7 @@ import (
"go.probo.inc/probo/pkg/server/api"
trust_v1 "go.probo.inc/probo/pkg/server/api/trust/v1"
auth_server "go.probo.inc/probo/pkg/server/auth"
authz_server "go.probo.inc/probo/pkg/server/authz"
"go.probo.inc/probo/pkg/server/trust"
"go.probo.inc/probo/pkg/server/web"
trust_pkg "go.probo.inc/probo/pkg/trust"
@@ -65,6 +66,7 @@ type Server struct {
webServer *web.Server
trustServer *trust.Server
authServer *auth_server.Server
authzServer *authz_server.Server
router *chi.Mux
extraHeaderFields map[string]string
proboService *probo.Service
@@ -118,6 +120,18 @@ func NewServer(cfg Config) (*Server, error) {
return nil, err
}
authzServer, err := authz_server.NewServer(authz_server.Config{
Auth: cfg.Auth,
Authz: cfg.Authz,
Logger: cfg.Logger.Named("authz"),
CookieName: cfg.ConsoleAuth.CookieName,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
})
if err != nil {
return nil, err
}
router := chi.NewRouter()
server := &Server{
@@ -125,6 +139,7 @@ func NewServer(cfg Config) (*Server, error) {
webServer: webServer,
trustServer: trustServer,
authServer: authServer,
authzServer: authzServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
proboService: cfg.Probo,
@@ -139,6 +154,7 @@ func NewServer(cfg Config) (*Server, error) {
func (s *Server) setupRoutes() {
s.router.Mount("/api", s.apiServer)
s.router.Mount("/connect", s.authServer)
s.router.Mount("/authz", s.authzServer)
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
r.Use(s.loadTrustCenterBySlugOrID)