package api import ( auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-public/audit-go.git/gen/go/audit/v1" "encoding/base64" "encoding/json" "errors" "fmt" "github.com/google/uuid" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" "net" "net/url" "regexp" "slices" "strings" "time" ) var ErrInvalidRequestBody = errors.New("invalid request body") var ErrInvalidResponse = errors.New("invalid response") var ErrInvalidAuthorizationHeaderValue = errors.New("invalid authorization header value") var ErrInvalidBearerToken = errors.New("invalid bearer token") var ErrTokenIsNotBearerToken = errors.New("token is not a bearer token") var objectTypeIdPattern, _ = regexp.Compile(".*/(projects|folders|organizations)/([0-9a-fA-F-]{36})(?:/.*)?") type ApiRequest struct { // Body // // Required: false Body *[]byte // The (HTTP) request headers / gRPC metadata. // // Internal IP-Addresses have to be removed (e.g. in x-forwarded-xxx headers). // // Required: true Header map[string][]string // The HTTP request `Host` header value. // // Required: true Host string // Method // // Required: true Method string // The URL scheme, such as `http`, `https` or `gRPC`. // // Required: true Scheme string // The network protocol used with the request, such as "http/1.1", // "spdy/3", "h2", "h2c", "webrtc", "tcp", "udp", "quic". See // https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids // for details. // // Required: true Proto string // The url // // Required: true URL RequestUrl } type RequestUrl struct { // The gRPC / HTTP URL path. // // Required: true Path string // The HTTP URL query in the format of "name1=value1&name2=value2", as it // appears in the first line of the HTTP request. // The input should be escaped to not contain any special characters. // // Required: false RawQuery *string } // AuditRequest bundles request related parameters type AuditRequest struct { // The operation request. This may not include all request parameters, // such as those that are too large, privacy-sensitive, or duplicated // elsewhere in the log record. // It should never include user-generated data, such as file contents. // // Required: true Request *ApiRequest // The IP address of the caller. // For caller from internet, this will be public IPv4 or IPv6 address. // For caller from a VM / K8s Service / etc., this will be the SIT proxy's IPv4 address. // // Required: true RequestClientIP string // Correlate multiple audit logs by setting the same id // // Required: false RequestCorrelationId *string // The unique ID for a request, which can be propagated to downstream // systems. The ID should have low probability of collision // within a single day for a specific service. // // More information can be found here: https://google.aip.dev/155 // // Format: // Where: // Idempotency-key: Typically consists of an id + version // // Examples: // 5e3952a9-b628-4be6-ac61-b1c6eb4a110c/5 // // Required: false RequestId *string // The timestamp when the `destination` service receives the first byte of // the request. // // Required: false RequestTime *time.Time } // AuditResponse bundles response related parameters type AuditResponse struct { // The operation response. This may not include all response elements, // such as those that are too large, privacy-sensitive, or duplicated // elsewhere in the log record. // // Required: false ResponseBodyBytes *[]byte // The http or gRPC status code. // // Examples: // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status // https://grpc.github.io/grpc/core/md_doc_statuscodes.html // // Required: true ResponseStatusCode int // The HTTP response headers. // // Required: true ResponseHeaders map[string][]string // The number of items returned from a List or Query API method, // if applicable. // // Required: false ResponseNumItems *int64 // The timestamp when the "destination" service generates the first byte of // the response. // // Required: false ResponseTime *time.Time } // AuditMetadata bundles audit event related metadata type AuditMetadata struct { // A unique identifier for the log entry. // Is used to check completeness of audit events over time. // // Format: /// // Where: // Unix-Timestamp: A UTC unix timestamp in seconds is expected // Region-Zone: The region and (optional) zone id. If both, separated with a - (dash) // Worker-Id: The ID of the K8s Pod, Service-Instance, etc. (must be unique for a sending service) // Sequence-Number: Increasing number, representing the message offset per Worker-Id // If the Worker-Id changes, the sequence-number has to be reset to 0. // // Examples: // "1721899117/eu01/319a7fb9-edd2-46c6-953a-a724bb377c61/8792726390909855142" // // Required: true AuditInsertId string // A set of user-defined (key, value) data that provides additional // information about the log entry. // // Required: false AuditLabels *map[string]string // The resource name of the log to which this log entry belongs. // // Format: //logs/ // Where: // Plural-Types: One from the list of supported ObjectType as plural // Event-Types: admin-activity, system-event, policy-denied, data-access // // Examples: // "projects/00b0f972-59ff-48f2-a4f9-29c57b75c2fa/logs/admin-activity" // // Required: true AuditLogName string // The severity of the log entry. // // Required: true AuditLogSeverity auditV1.LogSeverity // The name of the service method or operation. // // Format: stackit.... // Where: // Product: The name of the service in lowercase // Version: Optional API version // Type-Chain: Optional chained path to object // Operation: The name of the operation in lowercase // // Examples: // "stackit.resource-manager.v1.organizations.create" // "stackit.authorization.v1.projects.volumes.create" // "stackit.authorization.v2alpha.projects.volumes.create" // "stackit.authorization.v2.folders.move" // "stackit.resource-manager.health" // // Required: true AuditOperationName string // The required IAM permission. // // Examples: // "resourcemanager.project.edit" // // Required: false AuditPermission *string // Result of the IAM permission check. // // Required: false AuditPermissionGranted *bool // The resource or collection that is the target of the operation. // The name is a scheme-less URI, not including the API service name. // // Format: /[/locations/][/
] // Where: // Plural-Type: One from the list of supported ObjectType as plural // Id: The identifier of the object // Region-Zone: Optional region and zone id. If both, separated with a - (dash). Alternatively _ (underscore). // Details: Optional "/" pairs // // Examples: // "organizations/40ab14ad-b7b0-4b1c-be41-5bc820a968d1" // "projects/7046e7b6-5ae9-441c-99fe-2cd28a5078ec/locations/_/instances/instance-20240723-174217" // "projects/7046e7b6-5ae9-441c-99fe-2cd28a5078ec/locations/eu01/instances/instance-20240723-174217" // "projects/7046e7b6-5ae9-441c-99fe-2cd28a5078ec/locations/sx-stoi01/instances/instance-20240723-174217" // "projects/dd7d1807-54e9-4426-8994-721758b5b554/locations/eu01-m/vms/b6851b4e-7a9d-4973-ab0f-a80a13ee3060/ports/78f8bad4-a291-4fa3-b07f-4a1985d3dbe8" // // Required: true AuditResourceName string // The name of the API service performing the operation. // // Examples: // "resource-manager" // // Required: true AuditServiceName string // The time the event described by the log entry occurred. // // Required: false AuditTime *time.Time } // NewAuditLogEntry constructs a new audit log event for the given parameters func NewAuditLogEntry( // Required request parameters auditRequest AuditRequest, // Required response parameters auditResponse AuditResponse, // Optional map that is added as "details" to the message eventMetadata *map[string]interface{}, // Required metadata auditMetadata AuditMetadata, ) (*auditV1.AuditLogEntry, error) { // Get request headers filteredRequestHeaders := FilterAndMergeHeaders(auditRequest.Request.Header) filteredResponseHeaders := FilterAndMergeHeaders(auditResponse.ResponseHeaders) // Get response body responseBody, err := NewResponseBody(auditResponse.ResponseBodyBytes) if err != nil { return nil, errors.Join(err, ErrInvalidResponse) } var responseLength *int64 = nil if responseBody != nil { length := int64(len(*auditResponse.ResponseBodyBytes)) responseLength = &length } // Get request body requestBody, err := NewRequestBody(auditRequest.Request) if err != nil { return nil, errors.Join(err, ErrInvalidRequestBody) } // Get audit attributes from request auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := AuditAttributesFromAuthorizationHeader(auditRequest.Request) if err != nil { return nil, err } // Get request scheme (http, https) scheme := auditRequest.Request.Scheme // Initialize authorization info if available var authorizationInfo []*auditV1.AuthorizationInfo = nil if auditMetadata.AuditPermission != nil && auditMetadata.AuditPermissionGranted != nil { authorizationInfo = []*auditV1.AuthorizationInfo{ NewAuthorizationInfo( auditMetadata.AuditResourceName, *auditMetadata.AuditPermission, *auditMetadata.AuditPermissionGranted)} } // Initialize labels if available var labels map[string]string = nil if auditMetadata.AuditLabels != nil { labels = *auditMetadata.AuditLabels } // Initialize metadata/details var metadata *structpb.Struct = nil if eventMetadata != nil { metadataStruct, err := structpb.NewStruct(*eventMetadata) if err != nil { return nil, err } metadata = metadataStruct } // Get request and audit time var concreteRequestTime = time.Now().UTC() if auditRequest.RequestTime != nil { concreteRequestTime = *auditRequest.RequestTime } var concreteAuditTime = concreteRequestTime if auditMetadata.AuditTime != nil { concreteAuditTime = *auditMetadata.AuditTime } var concreteResponseTime = concreteRequestTime if auditResponse.ResponseTime != nil { concreteResponseTime = *auditResponse.ResponseTime } // Initialize the audit log entry event := auditV1.AuditLogEntry{ LogName: auditMetadata.AuditLogName, ProtoPayload: &auditV1.AuditLog{ ServiceName: auditMetadata.AuditServiceName, OperationName: auditMetadata.AuditOperationName, ResourceName: auditMetadata.AuditResourceName, AuthenticationInfo: authenticationInfo, AuthorizationInfo: authorizationInfo, RequestMetadata: NewRequestMetadata( auditRequest.Request, filteredRequestHeaders, auditRequest.RequestId, scheme, concreteRequestTime, auditRequest.RequestClientIP, authenticationPrincipal, audiences, auditClaims), Request: requestBody, ResponseMetadata: NewResponseMetadata( auditResponse.ResponseStatusCode, auditResponse.ResponseNumItems, responseLength, filteredResponseHeaders, concreteResponseTime), Response: responseBody, Metadata: metadata, }, InsertId: auditMetadata.AuditInsertId, Labels: labels, CorrelationId: auditRequest.RequestCorrelationId, Timestamp: timestamppb.New(concreteAuditTime), Severity: auditMetadata.AuditLogSeverity, } return &event, nil } // GetCalledServiceNameFromRequest extracts the called service name from subdomain name func GetCalledServiceNameFromRequest(request *ApiRequest, fallbackName string) string { if request == nil { return fallbackName } var calledServiceName = fallbackName host := request.Host ip := net.ParseIP(host) if ip == nil && !strings.Contains(host, "localhost") { dotIdx := strings.Index(host, ".") if dotIdx != -1 { calledServiceName = host[0:dotIdx] } } return calledServiceName } // NewPbInt64Value returns protobuf int64 wrapper if value is not nil. func NewPbInt64Value(value *int64) *wrapperspb.Int64Value { if value != nil { return wrapperspb.Int64(*value) } return nil } // NewRequestMetadata returns initialized protobuf RequestMetadata object. func NewRequestMetadata( request *ApiRequest, requestHeaders map[string]string, requestId *string, requestScheme string, requestTime time.Time, clientIp string, authenticationPrincipal string, audiences []string, auditClaims *structpb.Struct, ) *auditV1.RequestMetadata { agent := requestHeaders["User-Agent"] if agent == "" { agent = requestHeaders["user-agent"] } return &auditV1.RequestMetadata{ CallerIp: clientIp, CallerSuppliedUserAgent: agent, RequestAttributes: NewRequestAttributes( request, requestHeaders, requestId, requestScheme, requestTime, authenticationPrincipal, audiences, auditClaims, ), } } // NewRequestAttributes returns initialized protobuf AttributeContext_Request object. func NewRequestAttributes( request *ApiRequest, requestHeaders map[string]string, requestId *string, requestScheme string, requestTime time.Time, authenticationPrincipal string, audiences []string, auditClaims *structpb.Struct, ) *auditV1.AttributeContext_Request { rawQuery := request.URL.RawQuery var query *string = nil if rawQuery != nil && *rawQuery != "" { escapedQuery := url.QueryEscape(*rawQuery) query = &escapedQuery } return &auditV1.AttributeContext_Request{ Id: requestId, Method: StringToHttpMethod(request.Method), Headers: requestHeaders, Path: request.URL.Path, Host: request.Host, Scheme: requestScheme, Query: query, Time: timestamppb.New(requestTime), Protocol: request.Proto, Auth: &auditV1.AttributeContext_Auth{ Principal: authenticationPrincipal, Audiences: audiences, Claims: auditClaims, }, } } // NewAuthorizationInfo returns protobuf AuthorizationInfo for the given parameters. func NewAuthorizationInfo(resourceName string, permission string, granted bool) *auditV1.AuthorizationInfo { return &auditV1.AuthorizationInfo{ Resource: resourceName, Permission: &permission, Granted: &granted, } } // NewInsertId returns a correctly formatted insert id. func NewInsertId(insertTime time.Time, location string, workerId string, eventSequenceNumber uint64) string { return fmt.Sprintf("%d/%s/%s/%d", insertTime.UnixNano(), location, workerId, eventSequenceNumber) } // NewResponseMetadata returns protobuf response status with status code and short message. func NewResponseMetadata(statusCode int, numResponseItems *int64, responseSize *int64, headers map[string]string, responseTime time.Time) *auditV1.ResponseMetadata { var message *string = nil if statusCode >= 400 && statusCode < 500 { text := "Client error" message = &text } else if statusCode >= 500 { text := "Server error" message = &text } var size *wrapperspb.Int64Value = nil if responseSize != nil { size = wrapperspb.Int64(*responseSize) } return &auditV1.ResponseMetadata{ StatusCode: wrapperspb.Int32(int32(statusCode)), ErrorMessage: message, ErrorDetails: nil, ResponseAttributes: &auditV1.AttributeContext_Response{ NumResponseItems: NewPbInt64Value(numResponseItems), Size: size, Headers: headers, Time: timestamppb.New(responseTime), }, } } // NewResponseBody converts the JSON byte response into a protobuf struct. func NewResponseBody(response *[]byte) (*structpb.Struct, error) { // Return if nil if response == nil || len(*response) == 0 { return nil, nil } // Convert to protobuf struct return byteArrayToPbStruct(*response) } // NewRequestBody converts the request body into a protobuf struct. func NewRequestBody(request *ApiRequest) (*structpb.Struct, error) { if request.Body == nil || len(*request.Body) == 0 { return nil, nil } // Convert to protobuf struct return byteArrayToPbStruct(*request.Body) } // byteArrayToPbStruct converts a given json byte array into a protobuf struct. func byteArrayToPbStruct(bytes []byte) (*structpb.Struct, error) { var bodyMap map[string]interface{} err := json.Unmarshal(bytes, &bodyMap) if err != nil { return nil, err } return structpb.NewStruct(bodyMap) } // FilterAndMergeHeaders filters ":authority", "Authorization" and "B3" headers as well as // all headers starting with the prefixes "X-" and "STACKIT-". // Headers are merged if there is more than one value for a given name. func FilterAndMergeHeaders(headers map[string][]string) map[string]string { var resultMap = make(map[string]string) skipHeaders := []string{":authority", "authorization", "b3"} skipPrefixHeaders := []string{"x-", "stackit-"} if len(headers) == 0 { return nil } for headerName, headerValues := range headers { headerLower := strings.ToLower(headerName) // Check if headers with a specific prefix is found skip := false for _, skipPrefix := range skipPrefixHeaders { if strings.HasPrefix(headerLower, skipPrefix) { skip = true break } } // Keep header if not on filter list or value is empty if !skip && !slices.Contains(skipHeaders, headerLower) && len(headerValues) > 0 { resultMap[headerName] = strings.Join(headerValues, ",") } } return resultMap } // NewAuditRoutingIdentifier instantiates a new auditApi.RoutableIdentifier for // the given object ID and object type. func NewAuditRoutingIdentifier(objectId string, objectType ObjectType) *RoutableIdentifier { return &RoutableIdentifier{ Identifier: objectId, Type: objectType, } } // AuditAttributesFromAuthorizationHeader extracts the following claims from given http.Request: // - auditClaims - filtered list of claims // - authenticationPrincipal - principal identifier // - audiences - list of audience claims // - authenticationInfo - information about the user or service-account authentication func AuditAttributesFromAuthorizationHeader(request *ApiRequest) ( *structpb.Struct, string, []string, *auditV1.AuthenticationInfo, error, ) { var principalId = "none" var principalEmail = "do-not-reply@stackit.cloud" emptyClaims, _ := structpb.NewStruct(make(map[string]interface{})) var auditClaims = emptyClaims var authenticationPrincipal = "none/none" var serviceAccountName *string = nil audiences := make([]string, 0) var delegationInfo []*auditV1.ServiceAccountDelegationInfo = nil authorizationHeaders := request.Header["Authorization"] if len(authorizationHeaders) == 0 { // fallback for grpc where headers/metadata keys are lowercase authorizationHeaders = request.Header["authorization"] } authorizationHeader := strings.Join(authorizationHeaders, ",") trimmedAuthorizationHeader := strings.TrimSpace(authorizationHeader) if len(trimmedAuthorizationHeader) > 0 { // Parse claims parsedClaims, filteredClaims, err := parseClaimsFromAuthorizationHeader(trimmedAuthorizationHeader) if err != nil { return nil, authenticationPrincipal, nil, nil, err } // Convert filtered claims to protobuf struct auditClaimsStruct, err := structpb.NewStruct(filteredClaims) if err != nil { return nil, authenticationPrincipal, nil, nil, err } auditClaims = auditClaimsStruct // Extract principal data authenticationPrincipal = extractAuthenticationPrincipal(parsedClaims) principalId, principalEmail = extractSubjectAndEmail(parsedClaims) // Extract service account delegation info data delegationInfo = extractServiceAccountDelegationInfo(parsedClaims) // Extract audiences data audiences, err = extractAudiences(parsedClaims) if err != nil { return nil, authenticationPrincipal, nil, nil, err } // Extract project id and service account id projectId := extractServiceAccountProjectId(parsedClaims) serviceAccountId := extractServiceAccountId(parsedClaims) // Calculate service account name if project id and service account id are available if projectId != nil && serviceAccountId != nil { accountName := fmt.Sprintf("projects/%s/service-accounts/%s", *projectId, *serviceAccountId) serviceAccountName = &accountName } } authenticationInfo := auditV1.AuthenticationInfo{ PrincipalId: principalId, PrincipalEmail: principalEmail, ServiceAccountName: serviceAccountName, ServiceAccountDelegationInfo: delegationInfo, } return auditClaims, authenticationPrincipal, audiences, &authenticationInfo, nil } func extractServiceAccountId(parsedClaims map[string]interface{}) *string { projectId, projectIdExists := parsedClaims["stackit/serviceaccount/service-account.uid"] if projectIdExists { projectIdString := fmt.Sprintf("%s", projectId) return &projectIdString } else { return nil } } func extractServiceAccountProjectId(parsedClaims map[string]interface{}) *string { projectId, projectIdExists := parsedClaims["stackit/project/project.id"] if projectIdExists { projectIdString := fmt.Sprintf("%s", projectId) return &projectIdString } else { return nil } } func extractAudiences(parsedClaims map[string]interface{}) ([]string, error) { audClaim, _ := json.Marshal(parsedClaims["aud"]) var audiences []string if err := json.Unmarshal(audClaim, &audiences); err != nil { return nil, err } return audiences, nil } func extractAuthenticationPrincipal(parsedClaims map[string]interface{}) string { subClaim, subExists := parsedClaims["sub"] issuerClaim, issuerExists := parsedClaims["iss"] var principal = "none/none" if subExists && issuerExists { principal = fmt.Sprintf("%s/%s", url.QueryEscape(subClaim.(string)), url.QueryEscape(issuerClaim.(string))) } return principal } func parseClaimsFromAuthorizationHeader(authorizationHeader string) (map[string]interface{}, map[string]interface{}, error) { parts := strings.Split(authorizationHeader, " ") if len(parts) != 2 { return nil, nil, ErrInvalidAuthorizationHeaderValue } if !strings.EqualFold(parts[0], "Bearer") { return nil, nil, ErrTokenIsNotBearerToken } jwt := parts[1] authorizationHeaderParts := strings.Split(jwt, ".") parsedClaims := make(map[string]interface{}) if len(authorizationHeaderParts) == 3 { // base64 decoding decodedString, err := base64.RawURLEncoding.DecodeString(authorizationHeaderParts[1]) if err != nil { return parsedClaims, nil, errors.Join(err, ErrInvalidBearerToken) } // unmarshall claim part of token err = json.Unmarshal(decodedString, &parsedClaims) if err != nil { return parsedClaims, nil, err } // Collect user-friendly filtered subset of claims filteredClaims := make(map[string]interface{}) _ = json.Unmarshal(decodedString, &filteredClaims) keysToDelete := make([]string, 0) for key := range filteredClaims { if key != "aud" && key != "email" && key != "iss" && key != "jti" && key != "sub" { keysToDelete = append(keysToDelete, key) } } for _, key := range keysToDelete { delete(filteredClaims, key) } return parsedClaims, filteredClaims, nil } return parsedClaims, nil, ErrInvalidBearerToken } func extractServiceAccountDelegationInfoDetails(token map[string]interface{}) []*auditV1.ServiceAccountDelegationInfo { principalId, principalEmail := extractSubjectAndEmail(token) delegation := auditV1.ServiceAccountDelegationInfo{Authority: &auditV1.ServiceAccountDelegationInfo_IdpPrincipal_{IdpPrincipal: &auditV1.ServiceAccountDelegationInfo_IdpPrincipal{ PrincipalId: principalId, PrincipalEmail: principalEmail, ServiceMetadata: nil, }}} delegations := []*auditV1.ServiceAccountDelegationInfo{&delegation} nestedDelegations := extractServiceAccountDelegationInfo(token) if len(nestedDelegations) > 0 { return append(delegations, nestedDelegations...) } else { return delegations } } func extractServiceAccountDelegationInfo(token map[string]interface{}) []*auditV1.ServiceAccountDelegationInfo { actor := token["act"] if actor != nil { actorMap, hasActorClaim := actor.(map[string]interface{}) if hasActorClaim { return extractServiceAccountDelegationInfoDetails(actorMap) } } return nil } func extractSubjectAndEmail(token map[string]interface{}) (string, string) { var principalEmail string principalId := fmt.Sprintf("%s", token["sub"]) principalEmailRaw := token["email"] if principalEmailRaw == nil { principalEmail = "do-not-reply@stackit.cloud" } else { principalEmail = fmt.Sprintf("%s", principalEmailRaw) } return principalId, principalEmail } // OperationNameFromUrlPath converts the request url path into an operation name. // UUIDs and query parameters are filtered out, slashes replaced by dots. // HTTP methods are added as suffix as follows: // - POST - create // - PUT - update // - PATCH - update // - DELETE - delete // - others - read func OperationNameFromUrlPath(path string, requestMethod string) string { queryIdx := strings.Index(path, "?") if queryIdx != -1 { path = path[:queryIdx] } path = strings.TrimPrefix(path, "/") path = strings.TrimSuffix(path, "/") split := strings.Split(path, "/") operation := "" for _, part := range split { // skip uuids in path _, err := uuid.Parse(part) if err == nil { continue } operation = fmt.Sprintf("%s/%s", operation, part) } operation = strings.ReplaceAll(operation, "/", ".") operation = strings.TrimPrefix(operation, ".") operation = strings.ToLower(operation) if len(operation) > 0 { method := StringToHttpMethod(requestMethod) var action string switch method { case auditV1.AttributeContext_HTTP_METHOD_PUT: fallthrough case auditV1.AttributeContext_HTTP_METHOD_PATCH: action = "update" case auditV1.AttributeContext_HTTP_METHOD_POST: action = "create" case auditV1.AttributeContext_HTTP_METHOD_DELETE: action = "delete" default: action = "read" } operation = fmt.Sprintf("%s.%s", operation, action) } return operation } // OperationNameFromGrpcMethod converts the grpc path into an operation name. func OperationNameFromGrpcMethod(path string) string { operation := strings.TrimPrefix(path, "/") operation = strings.TrimSuffix(operation, "/") operation = strings.ReplaceAll(operation, "/", ".") operation = strings.TrimPrefix(operation, ".") operation = strings.ToLower(operation) return operation } func GetObjectIdAndTypeFromUrlPath(path string) ( string, *ObjectType, error, ) { // Extract object id and type from request url objectTypeIdMatches := objectTypeIdPattern.FindStringSubmatch(path) if len(objectTypeIdMatches) > 0 { objectType := ObjectTypeFromPluralString(objectTypeIdMatches[1]) err := objectType.IsSupportedType() if err != nil { return "", nil, err } objectId := objectTypeIdMatches[2] return objectId, &objectType, nil } return "", nil, nil } func ToArrayMap(input map[string]string) map[string][]string { output := map[string][]string{} for key, value := range input { output[key] = []string{value} } return output } func StringAttributeFromMetadata(metadata map[string][]string, name string) string { var value = "" rawValue, hasAttribute := metadata[name] if hasAttribute && len(rawValue) > 0 { value = rawValue[0] } return value } // ResponseBodyToBytes converts a JSON or Protobuf response into a byte array func ResponseBodyToBytes(response any) (*[]byte, error) { if response == nil { return nil, nil } responseBytes, isBytes := response.([]byte) if isBytes { return &responseBytes, nil } responseProtoMessage, isProtoMessage := response.(proto.Message) if isProtoMessage { responseJson, err := protojson.Marshal(responseProtoMessage) if err != nil { return nil, err } return &responseJson, nil } else { responseJson, err := json.Marshal(response) if err != nil { return nil, err } return &responseJson, nil } }