From 61ac70374393b25960a7d34b75ba4e4babb9a434 Mon Sep 17 00:00:00 2001 From: Christian Schaible Date: Wed, 4 Sep 2024 15:01:27 +0200 Subject: [PATCH] Add reusable code to create audit events --- audit/api/log.go | 84 +++ audit/api/model.go | 897 ++++++++++++++++++++++ audit/api/model_test.go | 988 +++++++++++++++++++++++++ audit/utils/sequence_generator.go | 45 ++ audit/utils/sequence_generator_test.go | 22 + go.mod | 4 +- 6 files changed, 2038 insertions(+), 2 deletions(-) create mode 100644 audit/api/log.go create mode 100644 audit/api/model.go create mode 100644 audit/api/model_test.go create mode 100644 audit/utils/sequence_generator.go create mode 100644 audit/utils/sequence_generator_test.go diff --git a/audit/api/log.go b/audit/api/log.go new file mode 100644 index 0000000..d64abf9 --- /dev/null +++ b/audit/api/log.go @@ -0,0 +1,84 @@ +package api + +import ( + auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/gen/go/audit/v1" + "encoding/json" + "google.golang.org/protobuf/encoding/protojson" + "log/slog" + "time" +) + +// LogEvent logs an event to the terminal +func LogEvent( + event *CloudEvent, + auditEvent *auditV1.AuditLogEntry, + routableIdentifier *RoutableIdentifier, + visibility auditV1.Visibility, +) error { + + // Convert to json + auditEventJson, err := protojson.Marshal(auditEvent) + if err != nil { + return err + } + auditEventMap := make(map[string]interface{}) + err = json.Unmarshal(auditEventJson, &auditEventMap) + if err != nil { + return err + } + + objectIdentifierJson, err := protojson.Marshal(routableIdentifier.ToObjectIdentifier()) + if err != nil { + return err + } + objectIdentifierMap := make(map[string]interface{}) + err = json.Unmarshal(objectIdentifierJson, &objectIdentifierMap) + if err != nil { + return err + } + + cloudEvent := cloudEvent{ + SpecVersion: event.SpecVersion, + Source: event.Source, + Id: event.Id, + Time: event.Time, + DataContentType: event.DataContentType, + DataType: event.DataType, + Subject: event.Subject, + Data: routableEvent{ + OperationName: auditEvent.ProtoPayload.OperationName, + Visibility: visibility.String(), + ResourceReference: objectIdentifierMap, + Data: auditEventMap, + }, + TraceParent: event.TraceParent, + TraceState: event.TraceState, + } + cloudEventJson, err := json.Marshal(cloudEvent) + if err != nil { + return err + } + + slog.Info(string(cloudEventJson)) + return nil +} + +type cloudEvent struct { + SpecVersion string + Source string + Id string + Time time.Time + DataContentType string + DataType string + Subject string + Data routableEvent + TraceParent *string + TraceState *string +} + +type routableEvent struct { + OperationName string + Visibility string + ResourceReference map[string]interface{} + Data map[string]interface{} +} diff --git a/audit/api/model.go b/audit/api/model.go new file mode 100644 index 0000000..50fc3b2 --- /dev/null +++ b/audit/api/model.go @@ -0,0 +1,897 @@ +package api + +import ( + "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/audit/utils" + auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/gen/go/audit/v1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" + "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 Request struct { + Body *[]byte + Header map[string][]string + Host string + Method string + Scheme string + Proto string + URL RequestUrl +} + +type RequestUrl struct { + Path string + 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: false + Request *Request + + // 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 a 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 data types + // 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: 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 data types + // 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/dd7d1807-54e9-4426-8994-721758b5b554/locations/eu01/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, + + // Optional W3C trace parent + userProvidedTraceParent *string, + + // Optional W3C trace state + userProvidedTraceState *string, +) (*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, 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, 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, + TraceParent: userProvidedTraceParent, + TraceState: userProvidedTraceState, + } + return &event, nil +} + +// GetCalledServiceNameFromRequest extracts the called service name from subdomain name +func GetCalledServiceNameFromRequest(request *Request, fallbackName string) string { + var calledServiceName = fallbackName + host := request.Host + if !strings.Contains(host, "localhost") { + dotIdx := strings.Index(host, ".") + if dotIdx != -1 { + calledServiceName = host[0:dotIdx] + } + } + return calledServiceName +} + +// AuditSpan is an abstraction for trace.Span that can easier be tested +type AuditSpan interface { + SpanContext() trace.SpanContext +} + +// TraceParentFromSpan returns W3C conform trace parent from AuditSpan +func TraceParentFromSpan(span AuditSpan) string { + traceVersion := "00" + traceId := span.SpanContext().TraceID().String() + parentId := span.SpanContext().SpanID().String() + // Trace flags according to W3C documentation: + // https://www.w3.org/TR/trace-context/#sampled-flag + var traceFlags = "00" + if span.SpanContext().TraceFlags().IsSampled() { + traceFlags = "01" + } + + // Format: --- + // Example: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + w3cTraceParent := fmt.Sprintf("%s-%s-%s-%s", traceVersion, traceId, parentId, traceFlags) + return w3cTraceParent +} + +// 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 *Request, + requestHeaders map[string]string, + requestId *string, + requestScheme string, + requestTime time.Time, + clientIp string, + authenticationPrincipal string, + audiences []string, + auditClaims *structpb.Struct, +) *auditV1.RequestMetadata { + + return &auditV1.RequestMetadata{ + CallerIp: clientIp, + CallerSuppliedUserAgent: requestHeaders["User-Agent"], + RequestAttributes: NewRequestAttributes( + request, + requestHeaders, + requestId, + requestScheme, + requestTime, + authenticationPrincipal, + audiences, + auditClaims, + ), + } +} + +// NewRequestAttributes returns initialized protobuf AttributeContext_Request object. +func NewRequestAttributes( + request *Request, + 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 != "" { + escapedQuery := url.QueryEscape(rawQuery) + query = &escapedQuery + } + + return &auditV1.AttributeContext_Request{ + Id: requestId, + Method: utils.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 it. +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 *Request) (*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 the "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{"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 + } + } + + // 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 singular type. +func NewAuditRoutingIdentifier(objectId string, singularType SingularType) *RoutableIdentifier { + return &RoutableIdentifier{ + Identifier: objectId, + Type: singularType, + } +} + +// 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 *Request) ( + *structpb.Struct, + string, + []string, + *auditV1.AuthenticationInfo, + error, +) { + + var principalId string + var principalEmail string + var auditClaims *structpb.Struct = nil + var authenticationPrincipal = "none/none" + var serviceAccountName *string = nil + audiences := make([]string, 0) + var delegationInfo []*auditV1.ServiceAccountDelegationInfo = nil + + 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/serviceAccounts/%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, 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 := utils.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, + *SingularType, + *PluralType, + error, +) { + + // Extract object id and type from request url + objectTypeIdMatches := objectTypeIdPattern.FindStringSubmatch(path) + if len(objectTypeIdMatches) > 0 { + objectTypePlural := AsPluralType(objectTypeIdMatches[1]) + objectTypeSingular, err := objectTypePlural.AsSingularType() + if err != nil { + return "", nil, nil, err + } + objectType := &objectTypeSingular + objectId := objectTypeIdMatches[2] + + return objectId, objectType, &objectTypePlural, nil + } + + return "", nil, 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 +} diff --git a/audit/api/model_test.go b/audit/api/model_test.go new file mode 100644 index 0000000..e2df7aa --- /dev/null +++ b/audit/api/model_test.go @@ -0,0 +1,988 @@ +package api + +import ( + "context" + auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/gen/go/audit/v1" + "encoding/json" + "fmt" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +type mockSpan struct { + spanContext trace.SpanContext +} + +func (s *mockSpan) SpanContext() trace.SpanContext { + return s.spanContext +} + +func Test_TraceParentFromSpan(t *testing.T) { + tracer := otel.Tracer("test") + + verifyTraceParent := func(traceParent string, span trace.Span, isSampled bool) { + parts := strings.Split(traceParent, "-") + assert.Equal(t, 4, len(parts)) + + // trace version + assert.Equal(t, "00", parts[0]) + assert.Equal(t, span.SpanContext().TraceID().String(), parts[1]) + assert.Equal(t, span.SpanContext().SpanID().String(), parts[2]) + + var traceFlags = "00" + if isSampled { + traceFlags = "01" + } + assert.Equal(t, traceFlags, parts[3]) + } + + t.Run("sampled", func(t *testing.T) { + _, span := tracer.Start(context.Background(), "test") + updatedFlags := span.SpanContext().TraceFlags().WithSampled(true) + updatedContext := span.SpanContext().WithTraceFlags(updatedFlags) + + mockedSpan := mockSpan{ + spanContext: updatedContext, + } + + traceParent := TraceParentFromSpan(&mockedSpan) + verifyTraceParent(traceParent, span, true) + }) + + t.Run("non-sampled", func(t *testing.T) { + _, span := tracer.Start(context.Background(), "test") + traceParent := TraceParentFromSpan(span) + verifyTraceParent(traceParent, span, false) + }) + +} + +func Test_GetCalledServiceNameFromRequest(t *testing.T) { + + t.Run("localhost", func(t *testing.T) { + request := Request{Host: "localhost:8080"} + serviceName := GetCalledServiceNameFromRequest(&request, "resource-manager") + assert.Equal(t, "resource-manager", serviceName) + }) + + t.Run("cf", func(t *testing.T) { + request := Request{Host: "stackit-resource-manager-go-dev.apps.01.cf.eu01.stackit.cloud"} + serviceName := GetCalledServiceNameFromRequest(&request, "resource-manager") + assert.Equal(t, "stackit-resource-manager-go-dev", serviceName) + }) + + t.Run("cf invalid host", func(t *testing.T) { + request := Request{Host: ""} + serviceName := GetCalledServiceNameFromRequest(&request, "resource-manager") + assert.Equal(t, "resource-manager", serviceName) + }) +} + +func Test_NewPbInt64Value(t *testing.T) { + + t.Run("nil", func(t *testing.T) { + value := NewPbInt64Value(nil) + assert.Nil(t, value) + }) + + t.Run("value", func(t *testing.T) { + var input int64 = 1 + value := NewPbInt64Value(&input) + assert.Equal(t, wrapperspb.Int64Value{Value: 1}.Value, value.Value) + }) +} + +func Test_NewResponseMetadata(t *testing.T) { + + headers := make(map[string]string) + headers["Content-Type"] = "application/json" + responseTime := time.Now().UTC() + responseItems := int64(10) + responseSize := int64(100) + + t.Run("no error", func(t *testing.T) { + for code := 1; code < 400; code++ { + metadata := NewResponseMetadata(code, &responseItems, &responseSize, headers, responseTime) + assert.Equal(t, wrapperspb.Int32(int32(code)).Value, metadata.StatusCode.Value) + assert.Nil(t, metadata.ErrorMessage) + assert.Nil(t, metadata.ErrorDetails) + assert.Equal(t, wrapperspb.Int64(responseItems), metadata.ResponseAttributes.NumResponseItems) + assert.Equal(t, wrapperspb.Int64(responseSize), metadata.ResponseAttributes.Size) + assert.Equal(t, timestamppb.New(responseTime), metadata.ResponseAttributes.Time) + } + }) + + t.Run("client error", func(t *testing.T) { + for code := 400; code < 500; code++ { + metadata := NewResponseMetadata(code, nil, nil, headers, responseTime) + assert.Equal(t, wrapperspb.Int32(int32(code)).Value, metadata.StatusCode.Value) + assert.Equal(t, "Client error", *metadata.ErrorMessage) + assert.Nil(t, metadata.ErrorDetails) + assert.Nil(t, metadata.ResponseAttributes.NumResponseItems) + assert.Nil(t, metadata.ResponseAttributes.Size) + assert.Equal(t, timestamppb.New(responseTime), metadata.ResponseAttributes.Time) + } + }) + + t.Run("server error", func(t *testing.T) { + for code := 500; code < 600; code++ { + metadata := NewResponseMetadata(code, nil, nil, headers, responseTime) + assert.Equal(t, wrapperspb.Int32(int32(code)).Value, metadata.StatusCode.Value) + assert.Equal(t, "Server error", *metadata.ErrorMessage) + assert.Nil(t, metadata.ErrorDetails) + assert.Nil(t, metadata.ResponseAttributes.NumResponseItems) + assert.Nil(t, metadata.ResponseAttributes.Size) + assert.Equal(t, timestamppb.New(responseTime), metadata.ResponseAttributes.Time) + } + }) +} + +func Test_NewRequestMetadata(t *testing.T) { + + userAgent := "userAgent" + requestHeaders := make(map[string][]string) + requestHeaders["User-Agent"] = []string{userAgent} + requestHeaders["Custom"] = []string{"customHeader"} + + request := Request{ + Method: "GET", + URL: RequestUrl{Path: "/audit/new", RawQuery: "topic=project"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Scheme: "http", + Header: requestHeaders, + } + + requestId := "requestId" + requestScheme := "requestScheme" + requestTime := time.Now().UTC() + + audiences := []string{"audience"} + authenticationPrincipal := "authenticationPrincipal" + + claimMap := make(map[string]interface{}) + auditClaims, _ := structpb.NewStruct(claimMap) + + clientIp := "clientIp" + + filteredHeaders := make(map[string]string) + filteredHeaders["Custom"] = "customHeader" + filteredHeaders["User-Agent"] = userAgent + + verifyRequestMetadata := func(requestMetadata *auditV1.RequestMetadata, requestId *string) { + assert.Equal(t, clientIp, requestMetadata.CallerIp) + assert.Equal(t, userAgent, requestMetadata.CallerSuppliedUserAgent) + assert.NotNil(t, requestMetadata.RequestAttributes) + + attributes := requestMetadata.RequestAttributes + assert.Equal(t, requestId, attributes.Id) + assert.Equal(t, filteredHeaders, attributes.Headers) + assert.Equal(t, request.URL.Path, attributes.Path) + assert.Equal(t, request.Host, attributes.Host) + assert.Equal(t, requestScheme, attributes.Scheme) + assert.Equal(t, timestamppb.New(requestTime), attributes.Time) + assert.Equal(t, request.Proto, attributes.Protocol) + assert.NotNil(t, attributes.Auth) + + auth := attributes.Auth + assert.Equal(t, authenticationPrincipal, auth.Principal) + assert.Equal(t, audiences, auth.Audiences) + assert.Equal(t, auditClaims, auth.Claims) + } + + t.Run("with query parameters", func(t *testing.T) { + requestMetadata := NewRequestMetadata( + &request, + filteredHeaders, + &requestId, + requestScheme, + requestTime, + clientIp, + authenticationPrincipal, + audiences, + auditClaims, + ) + + verifyRequestMetadata(requestMetadata, &requestId) + assert.Equal(t, "topic%3Dproject", *requestMetadata.RequestAttributes.Query) + }) + + t.Run("without query parameters", func(t *testing.T) { + request := Request{ + Method: "GET", + URL: RequestUrl{Path: "/audit/new", RawQuery: ""}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Header: requestHeaders, + } + + requestMetadata := NewRequestMetadata( + &request, + filteredHeaders, + &requestId, + requestScheme, + requestTime, + clientIp, + authenticationPrincipal, + audiences, + auditClaims, + ) + + verifyRequestMetadata(requestMetadata, &requestId) + assert.Nil(t, requestMetadata.RequestAttributes.Query) + }) + + t.Run("without request id", func(t *testing.T) { + request := Request{ + Method: "GET", + URL: RequestUrl{Path: "/audit/new", RawQuery: "topic=project"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Header: requestHeaders, + } + + requestMetadata := NewRequestMetadata( + &request, filteredHeaders, + nil, + requestScheme, + requestTime, + clientIp, + authenticationPrincipal, + audiences, + auditClaims, + ) + verifyRequestMetadata(requestMetadata, nil) + }) + + t.Run("various default http methods", func(t *testing.T) { + httpMethods := []string{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"} + for _, httpMethod := range httpMethods { + request := Request{ + Method: httpMethod, + URL: RequestUrl{Path: "/audit/new", RawQuery: "topic=project"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Header: requestHeaders, + } + + requestMetadata := NewRequestMetadata( + &request, filteredHeaders, + &requestId, requestScheme, + requestTime, clientIp, + authenticationPrincipal, + audiences, + auditClaims, + ) + + verifyRequestMetadata(requestMetadata, &requestId) + expectedMethod := fmt.Sprintf("HTTP_METHOD_%s", httpMethod) + assert.Equal(t, expectedMethod, requestMetadata.RequestAttributes.Method.String()) + } + }) + + t.Run("unknown http method", func(t *testing.T) { + request := Request{ + Method: "", + URL: RequestUrl{Path: "/audit/new", RawQuery: "topic=project"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Header: requestHeaders, + } + + requestMetadata := NewRequestMetadata( + &request, filteredHeaders, + &requestId, requestScheme, + requestTime, clientIp, + authenticationPrincipal, + audiences, + auditClaims, + ) + + verifyRequestMetadata(requestMetadata, &requestId) + assert.Equal(t, + auditV1.AttributeContext_HTTP_METHOD_UNSPECIFIED.String(), + requestMetadata.RequestAttributes.Method.String()) + + }) +} + +func Test_FilterAndMergeRequestHeaders(t *testing.T) { + + t.Run("skip headers", func(t *testing.T) { + headers := make(map[string][]string) + headers["Authorization"] = []string{"ey..."} + headers["B3"] = []string{"80f198ee56343ba864fe8b2a57d3eff7-e457b5a2e4d86bd1-1-05e3ac9a4f6e3b90"} + + filteredHeaders := FilterAndMergeHeaders(headers) + assert.Equal(t, 0, len(filteredHeaders)) + }) + + t.Run("skip headers by prefix", func(t *testing.T) { + headers := make(map[string][]string) + headers["X-Forwarded-Proto"] = []string{"https"} + headers["Stackit-test"] = []string{"test"} + + filteredHeaders := FilterAndMergeHeaders(headers) + assert.Equal(t, 0, len(filteredHeaders)) + }) + + t.Run("merge headers", func(t *testing.T) { + headers := make(map[string][]string) + headers["Custom1"] = []string{"value1", "value2"} + headers["Custom2"] = []string{"value3", "value4"} + + filteredHeaders := FilterAndMergeHeaders(headers) + assert.Equal(t, 2, len(filteredHeaders)) + assert.Equal(t, "value1,value2", filteredHeaders["Custom1"]) + assert.Equal(t, "value3,value4", filteredHeaders["Custom2"]) + }) +} + +func Test_AuditAttributesFromAuthorizationHeader(t *testing.T) { + + t.Run("basic token", func(t *testing.T) { + headerValue := "Basic username:password" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + _, _, _, _, err := AuditAttributesFromAuthorizationHeader(&request) + assert.ErrorIs(t, err, ErrTokenIsNotBearerToken) + }) + + t.Run("invalid header value", func(t *testing.T) { + headerValue := "a b c" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + _, _, _, _, err := AuditAttributesFromAuthorizationHeader(&request) + assert.ErrorIs(t, err, ErrInvalidAuthorizationHeaderValue) + }) + + t.Run("invalid token too many parts", func(t *testing.T) { + headerValue := "Bearer a.b.c.d" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + _, _, _, _, err := AuditAttributesFromAuthorizationHeader(&request) + assert.ErrorIs(t, err, ErrInvalidBearerToken) + }) + + t.Run("invalid bearer token", func(t *testing.T) { + headerValue := "Bearer a.b.c" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + _, _, _, _, err := AuditAttributesFromAuthorizationHeader(&request) + assert.ErrorIs(t, err, ErrInvalidBearerToken) + }) + + t.Run("client credentials token", func(t *testing.T) { + headerValue := "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1yZXNvdXJjZS1tYW5hZ2VyLWRldiJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXJlc291cmNlLW1hbmFnZXItZGV2IiwiZXhwIjoxNzI0NDA1MzI2LCJpYXQiOjE3MjQ0MDQ0MjYsImlzcyI6Imh0dHBzOi8vYWNjb3VudHMuZGV2LnN0YWNraXQuY2xvdWQiLCJqdGkiOiJlNDZlYmEzOC1kZWRiLTQ1NDEtOTRmMy00OWY5N2E5MzRkNTgiLCJuYmYiOjE3MjQ0MDQ0MjYsInNjb3BlIjoidWFhLm5vbmUiLCJzdWIiOiJzdGFja2l0LXJlc291cmNlLW1hbmFnZXItZGV2In0.JP5Uy7AMdK4ukzQ6aOYzbVwEmq0Tp2ppQGRqGOhuVQgbqs6yJ33GKXo7RPsJVLw3FR7XAxENIVqNvzGotbDXr0NjBGdzyxIHzrOaUqM4w1iLzD1KF51dXFwkoigqDdD7Ze9eI_Uo3tSn8FwGLTSoO-ONQYpnceCiGut2Gc6VIL8HOLdh8dzlRENGQtgYd-3Y5zqpoLrsR2Bd-0sv15sF-5aI0CqcC8gE70JPImKf2u_IYI-TYMDNk86YSCtaYO5-alOrHXXWwgzSoH-r2s5qoOhPbei9myV_P4fdcKXxMqfap9hImXPUooVhpdUr1AabZw3MtW7rION8tJAiauhMQA" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := + AuditAttributesFromAuthorizationHeader(&request) + + auditClaimsMap := auditClaims.AsMap() + assert.Nil(t, err) + assert.Equal(t, "https://accounts.dev.stackit.cloud", auditClaimsMap["iss"]) + assert.Equal(t, "stackit-resource-manager-dev", auditClaimsMap["sub"]) + assert.Equal(t, []interface{}{"stackit-resource-manager-dev"}, auditClaimsMap["aud"].([]interface{})) + assert.Equal(t, "e46eba38-dedb-4541-94f3-49f97a934d58", auditClaimsMap["jti"]) + + principal := fmt.Sprintf("%s/%s", + url.QueryEscape("stackit-resource-manager-dev"), + url.QueryEscape("https://accounts.dev.stackit.cloud")) + assert.Equal(t, principal, authenticationPrincipal) + + assert.Equal(t, []string{"stackit-resource-manager-dev"}, audiences) + + assert.Equal(t, "stackit-resource-manager-dev", authenticationInfo.PrincipalId) + assert.Equal(t, "do-not-reply@stackit.cloud", authenticationInfo.PrincipalEmail) + + assert.Nil(t, authenticationInfo.ServiceAccountName) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + }) + + t.Run("service account access token", func(t *testing.T) { + headerValue := "Bearer eyJraWQiOiJaVFJqWlRNek5tSmlNRGt3TldJMU5USTRZVGxpT1RjMllUWXlZVE16WldNIiwiYWxnIjoiUlM1MTIifQ.eyJzdWIiOiIxMGYzOGIwMS01MzRiLTQ3YmItYTAzYS1lMjk0Y2EyYmU0ZGUiLCJhdWQiOlsic3RhY2tpdCIsImFwaSJdLCJzdGFja2l0L3NlcnZpY2VhY2NvdW50L3Rva2VuLnNvdXJjZSI6ImxlZ2FjeSIsInN0YWNraXQvc2VydmljZWFjY291bnQvbmFtZXNwYWNlIjoiYXBpIiwic3RhY2tpdC9wcm9qZWN0L3Byb2plY3QuaWQiOiJkYWNjNzgzMC04NDNlLTRjNWUtODZmZi1hYTBmYjUxZDYzNmYiLCJhenAiOiJjZDk0ZjAxYS1kZjJlLTQ0NTYtOTAyZS00OGY1ZTU3ZjBiNjMiLCJpc3MiOiJzdGFja2l0L3NlcnZpY2VhY2NvdW50Iiwic3RhY2tpdC9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQudWlkIjoiMTBmMzhiMDEtNTM0Yi00N2JiLWEwM2EtZTI5NGNhMmJlNGRlIiwiZXhwIjoxNzIyNjY5MzQzLCJpYXQiOjE3MjI1ODI5NDMsImVtYWlsIjoibXktc2VydmljZS15aWZjOWUxQHNhLnN0YWNraXQuY2xvdWQiLCJqdGkiOiI4NGMzMGE0Ni0xMDAxLTQzNmYtODU5Zi04OWMwYmExOWJlMWUifQ.hb8X9VKc9xViHgNMyFHT9ePj_lyEwTV1D2es8E278WtoCJ9-4GPPQGjhcLGGrigjnvpRYV2LKzNqpQslerT5lFT_pHACsryaAE0ImYjmoe-nutA7BBpYuM_JN6pk5VIjVFLTqRKeIvFexPacqS2Vo3YoK1GvxPB8WPWBbGIsBtMl-PTm8OTwwzooBOoCRhhMR-E1lFbAymLsc1JI4yDQKLLomvhEopgmocCnQ-P1QkiKMqdkNxiD_YYLLYTOApg6d62BhqpH66ziqx493AStdZ8d5Kjvf3e1knDhaxVwNCghQj7lSo2kNAqZe__g2tiXpiZNTXBFJ_5HgQMLh67wng" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := + AuditAttributesFromAuthorizationHeader(&request) + + auditClaimsMap := auditClaims.AsMap() + assert.Nil(t, err) + assert.Equal(t, "stackit/serviceaccount", auditClaimsMap["iss"]) + assert.Equal(t, "10f38b01-534b-47bb-a03a-e294ca2be4de", auditClaimsMap["sub"]) + assert.Equal(t, []interface{}{"stackit", "api"}, auditClaimsMap["aud"].([]interface{})) + assert.Equal(t, "84c30a46-1001-436f-859f-89c0ba19be1e", auditClaimsMap["jti"]) + + principal := fmt.Sprintf("%s/%s", + url.QueryEscape("10f38b01-534b-47bb-a03a-e294ca2be4de"), + url.QueryEscape("stackit/serviceaccount")) + assert.Equal(t, principal, authenticationPrincipal) + + assert.Equal(t, []string{"stackit", "api"}, audiences) + + assert.Equal(t, "10f38b01-534b-47bb-a03a-e294ca2be4de", authenticationInfo.PrincipalId) + assert.Equal(t, "my-service-yifc9e1@sa.stackit.cloud", authenticationInfo.PrincipalEmail) + + assert.Equal(t, + "projects/dacc7830-843e-4c5e-86ff-aa0fb51d636f/serviceAccounts/10f38b01-534b-47bb-a03a-e294ca2be4de", + *authenticationInfo.ServiceAccountName) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + }) + + t.Run("impersonated token of access token", func(t *testing.T) { + headerValue := "Bearer eyJraWQiOiJaVFJqWlRNek5tSmlNRGt3TldJMU5USTRZVGxpT1RjMllUWXlZVE16WldNIiwiYWxnIjoiUlM1MTIifQ.eyJzdWIiOiJmNDUwMDliMi02NDMzLTQzYzEtYjZjNy02MThjNDQzNTllNzEiLCJpc3MiOiJzdGFja2l0L3NlcnZpY2VhY2NvdW50IiwiYXVkIjpbInN0YWNraXQiLCJhcGkiXSwic3RhY2tpdC9zZXJ2aWNlYWNjb3VudC90b2tlbi5zb3VyY2UiOiJvYXV0aDIiLCJhY3QiOnsic3ViIjoiMDJhZWY1MTYtMzE3Zi00ZWMxLWExZGYtMWFjYmQ0ZDQ5ZmUzIn0sInN0YWNraXQvc2VydmljZWFjY291bnQvbmFtZXNwYWNlIjoiYXBpIiwic3RhY2tpdC9wcm9qZWN0L3Byb2plY3QuaWQiOiJkYWNjNzgzMC04NDNlLTRjNWUtODZmZi1hYTBmYjUxZDYzNmYiLCJhenAiOiIwMmFlZjUxNi0zMTdmLTRlYzEtYTFkZi0xYWNiZDRkNDlmZTMiLCJzdGFja2l0L3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiJmNDUwMDliMi02NDMzLTQzYzEtYjZjNy02MThjNDQzNTllNzEiLCJleHAiOjE3MjQwNjI5MDcsImlhdCI6MTcyNDA1OTMwNywiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtMi10ajlzcnQxQHNhLnN0YWNraXQuY2xvdWQiLCJqdGkiOiIzNzU1NTE4My0wMWI5LTQyNzAtYmRjMS02OWI0ZmNmZDVlZTkifQ.auBvvsIesFMAlWOCPCPC77DrrHF7gSKZwKs_Zry5KFvu2bpZZC1BcSXOc8b9eh0SzANI9M9aGJBhOzOm39-ZZ5XOQ-6_y1aWuEenYQ6kT5D3GzCUTMDzSi1lcZ4IG5nFMa_AAlVEN_7AMv7LHGtz49bWLJnAgeTo1cvof-OgP4mCQ5O6E0iyAq-5u8V8NJL7HIZy7BDe4J1mjfYhwKagrN7QFWu4fhN4TNS7d922X_6V489BhjRFRYjLW_qDnv912JorbGRz_XwNy_dPA81EkdMyKE0BJUezguJUEKEG2_JEi9O64Flcoi6x8cFHYhaDuMMSLipzePaHdyk2lQtH7Q" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := + AuditAttributesFromAuthorizationHeader(&request) + + auditClaimsMap := auditClaims.AsMap() + assert.Nil(t, err) + assert.Equal(t, "stackit/serviceaccount", auditClaimsMap["iss"]) + assert.Equal(t, "f45009b2-6433-43c1-b6c7-618c44359e71", auditClaimsMap["sub"]) + assert.Equal(t, []interface{}{"stackit", "api"}, auditClaimsMap["aud"].([]interface{})) + assert.Equal(t, "37555183-01b9-4270-bdc1-69b4fcfd5ee9", auditClaimsMap["jti"]) + + principal := fmt.Sprintf("%s/%s", + url.QueryEscape("f45009b2-6433-43c1-b6c7-618c44359e71"), + url.QueryEscape("stackit/serviceaccount")) + assert.Equal(t, principal, authenticationPrincipal) + + assert.Equal(t, []string{"stackit", "api"}, audiences) + + assert.Equal(t, "f45009b2-6433-43c1-b6c7-618c44359e71", authenticationInfo.PrincipalId) + assert.Equal(t, "service-account-2-tj9srt1@sa.stackit.cloud", authenticationInfo.PrincipalEmail) + + assert.Equal(t, + "projects/dacc7830-843e-4c5e-86ff-aa0fb51d636f/serviceAccounts/f45009b2-6433-43c1-b6c7-618c44359e71", + *authenticationInfo.ServiceAccountName) + assert.NotNil(t, authenticationInfo.ServiceAccountDelegationInfo) + + serviceAccountDelegationInfo := authenticationInfo.ServiceAccountDelegationInfo + assert.Equal(t, "02aef516-317f-4ec1-a1df-1acbd4d49fe3", serviceAccountDelegationInfo[0].GetIdpPrincipal().PrincipalId) + assert.Equal(t, "do-not-reply@stackit.cloud", serviceAccountDelegationInfo[0].GetIdpPrincipal().PrincipalEmail) + }) + + t.Run("impersonated token of impersonated access token", func(t *testing.T) { + headerValue := "Bearer eyJraWQiOiJaVFJqWlRNek5tSmlNRGt3TldJMU5USTRZVGxpT1RjMllUWXlZVE16WldNIiwiYWxnIjoiUlM1MTIifQ.eyJzdWIiOiIxNzM0YjRiNi0xZDVlLTQ4MTktOWI1MC0yOTkxN2ExYjlhZDUiLCJpc3MiOiJzdGFja2l0L3NlcnZpY2VhY2NvdW50IiwiYXVkIjpbInN0YWNraXQiLCJhcGkiXSwic3RhY2tpdC9zZXJ2aWNlYWNjb3VudC90b2tlbi5zb3VyY2UiOiJvYXV0aDIiLCJhY3QiOnsic3ViIjoiZjQ1MDA5YjItNjQzMy00M2MxLWI2YzctNjE4YzQ0MzU5ZTcxIiwiYWN0Ijp7InN1YiI6IjAyYWVmNTE2LTMxN2YtNGVjMS1hMWRmLTFhY2JkNGQ0OWZlMyJ9fSwic3RhY2tpdC9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJhcGkiLCJzdGFja2l0L3Byb2plY3QvcHJvamVjdC5pZCI6ImRhY2M3ODMwLTg0M2UtNGM1ZS04NmZmLWFhMGZiNTFkNjM2ZiIsImF6cCI6ImY0NTAwOWIyLTY0MzMtNDNjMS1iNmM3LTYxOGM0NDM1OWU3MSIsInN0YWNraXQvc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6IjE3MzRiNGI2LTFkNWUtNDgxOS05YjUwLTI5OTE3YTFiOWFkNSIsImV4cCI6MTcyNDA2Mjk2MywiaWF0IjoxNzI0MDU5MzYzLCJlbWFpbCI6InNlcnZpY2UtYWNjb3VudC0zLWZnaHN4dzFAc2Euc3RhY2tpdC5jbG91ZCIsImp0aSI6IjFmN2YxZWZjLTMzNDktNDExYS1hNWQ3LTIyNTVlMGE1YThhZSJ9.c1ae17bAtyOdmwXQbK37W-NTyOxo7iER5aHS_C0fU1qKl2BjOz708GLjH-_vxx9eKPeYznfI21_xlTaAvuG4Aco9f5YDK7fooTVHnDaOSSggqcEaDzDPrNXhhKEDxotJeq9zRMVCEStcbirjTounnLbuULRbO5GSY5jo-8n2UKxSZ2j5G_SjFHajdJwmzwvOttp08tdL8ck1uDdgVNBfcm0VIdb6WmgrCIUq5rmoa-cRPkdEurNtIEgEB_9U0Xh-SpmmsvFsWWeNIKz0e_5RCIyJonm_wMkGmblGegemkYL76ypeMNXTQsly1RozDIePfzHuZOWbySHSCd-vKQa2kw" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := + AuditAttributesFromAuthorizationHeader(&request) + + auditClaimsMap := auditClaims.AsMap() + assert.Nil(t, err) + assert.Equal(t, "stackit/serviceaccount", auditClaimsMap["iss"]) + assert.Equal(t, "1734b4b6-1d5e-4819-9b50-29917a1b9ad5", auditClaimsMap["sub"]) + assert.Equal(t, []interface{}{"stackit", "api"}, auditClaimsMap["aud"].([]interface{})) + assert.Equal(t, "1f7f1efc-3349-411a-a5d7-2255e0a5a8ae", auditClaimsMap["jti"]) + + principal := fmt.Sprintf("%s/%s", + url.QueryEscape("1734b4b6-1d5e-4819-9b50-29917a1b9ad5"), + url.QueryEscape("stackit/serviceaccount")) + assert.Equal(t, principal, authenticationPrincipal) + + assert.Equal(t, []string{"stackit", "api"}, audiences) + + assert.Equal(t, "1734b4b6-1d5e-4819-9b50-29917a1b9ad5", authenticationInfo.PrincipalId) + assert.Equal(t, "service-account-3-fghsxw1@sa.stackit.cloud", authenticationInfo.PrincipalEmail) + + assert.Equal(t, + "projects/dacc7830-843e-4c5e-86ff-aa0fb51d636f/serviceAccounts/1734b4b6-1d5e-4819-9b50-29917a1b9ad5", + *authenticationInfo.ServiceAccountName) + assert.NotNil(t, authenticationInfo.ServiceAccountDelegationInfo) + + serviceAccountDelegationInfo := authenticationInfo.ServiceAccountDelegationInfo + assert.Equal(t, "f45009b2-6433-43c1-b6c7-618c44359e71", serviceAccountDelegationInfo[0].GetIdpPrincipal().PrincipalId) + assert.Equal(t, "do-not-reply@stackit.cloud", serviceAccountDelegationInfo[0].GetIdpPrincipal().PrincipalEmail) + assert.Equal(t, "02aef516-317f-4ec1-a1df-1acbd4d49fe3", serviceAccountDelegationInfo[1].GetIdpPrincipal().PrincipalId) + assert.Equal(t, "do-not-reply@stackit.cloud", serviceAccountDelegationInfo[1].GetIdpPrincipal().PrincipalEmail) + }) + + t.Run("user token", func(t *testing.T) { + headerValue := "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg" + headers := make(map[string][]string) + headers["Authorization"] = []string{headerValue} + request := Request{Header: headers} + + auditClaims, authenticationPrincipal, audiences, authenticationInfo, err := + AuditAttributesFromAuthorizationHeader(&request) + + auditClaimsMap := auditClaims.AsMap() + assert.Nil(t, err) + assert.Equal(t, "https://accounts.dev.stackit.cloud", auditClaimsMap["iss"]) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", auditClaimsMap["sub"]) + assert.Equal(t, []interface{}{"stackit-portal-login-dev-client-id"}, auditClaimsMap["aud"].([]interface{})) + assert.Equal(t, "d73a67ac-d1ec-4b55-99d4-e953275f022a", auditClaimsMap["jti"]) + + principal := fmt.Sprintf("%s/%s", + url.QueryEscape("cd94f01a-df2e-4456-902e-48f5e57f0b63"), + url.QueryEscape("https://accounts.dev.stackit.cloud")) + assert.Equal(t, principal, authenticationPrincipal) + + assert.Equal(t, []string{"stackit-portal-login-dev-client-id"}, audiences) + + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + + assert.Nil(t, authenticationInfo.ServiceAccountName) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + }) +} + +func Test_NewAuditLogEntry(t *testing.T) { + + t.Run("minimum attributes set", func(t *testing.T) { + userAgent := "userAgent" + requestHeaders := make(map[string][]string) + requestHeaders["Authorization"] = []string{"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"} + requestHeaders["User-Agent"] = []string{userAgent} + requestHeaders["Custom"] = []string{"customHeader"} + + request := Request{ + Method: "GET", + URL: RequestUrl{Path: "/audit/new"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Scheme: "http", + Header: requestHeaders, + } + + clientIp := "127.0.0.1" + correlationId := uuid.NewString() + auditRequest := AuditRequest{ + Request: &request, + RequestClientIP: clientIp, + RequestCorrelationId: &correlationId, + RequestId: nil, + RequestTime: nil, + } + + statusCode := 200 + auditResponse := AuditResponse{ + ResponseBodyBytes: nil, + ResponseStatusCode: statusCode, + ResponseHeaders: nil, + ResponseNumItems: nil, + ResponseTime: nil, + } + + objectId := uuid.NewString() + logName := fmt.Sprintf("projects/%s/logs/%s", objectId, EventTypeAdminActivity) + serviceName := "resource-manager" + operationName := fmt.Sprintf("stackit.%s.v2.projects.updated", serviceName) + resourceName := fmt.Sprintf("projects/%s", objectId) + auditTime := time.Now().UTC() + insertId := fmt.Sprintf("%d/%s/%s/%d", auditTime.UnixNano(), "eu01", "1", 1) + + severity := auditV1.LogSeverity_LOG_SEVERITY_DEFAULT + auditMetadata := AuditMetadata{ + AuditInsertId: insertId, + AuditLabels: nil, + AuditLogName: logName, + AuditLogSeverity: severity, + AuditOperationName: operationName, + AuditPermission: nil, + AuditPermissionGranted: nil, + AuditResourceName: resourceName, + AuditServiceName: serviceName, + AuditTime: nil, + } + + logEntry, _ := NewAuditLogEntry( + auditRequest, + auditResponse, + nil, + auditMetadata, + nil, + nil) + + assert.Equal(t, logName, logEntry.LogName) + assert.Equal(t, insertId, logEntry.InsertId) + assert.Equal(t, &correlationId, logEntry.CorrelationId) + assert.Equal(t, severity, logEntry.Severity) + assert.NoError(t, logEntry.Timestamp.CheckValid()) + assert.Nil(t, logEntry.Labels) + assert.Nil(t, logEntry.TraceParent) + assert.Nil(t, logEntry.TraceState) + + payload := logEntry.ProtoPayload + assert.NotNil(t, payload) + assert.Equal(t, serviceName, payload.ServiceName) + assert.Equal(t, operationName, payload.OperationName) + assert.Equal(t, resourceName, payload.ResourceName) + assert.Equal(t, int32(statusCode), payload.ResponseMetadata.StatusCode.Value) + assert.Nil(t, payload.ResponseMetadata.ErrorMessage) + assert.Nil(t, payload.ResponseMetadata.ErrorDetails) + assert.Nil(t, payload.ResponseMetadata.ResponseAttributes.Size) + assert.NotNil(t, payload.ResponseMetadata.ResponseAttributes.Time) + assert.Nil(t, payload.ResponseMetadata.ResponseAttributes.NumResponseItems) + assert.Nil(t, payload.ResponseMetadata.ResponseAttributes.Headers) + + assert.Nil(t, payload.Request) + assert.Nil(t, payload.Response) + assert.Nil(t, payload.Metadata) + + authenticationInfo := payload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Nil(t, authenticationInfo.ServiceAccountName) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + + assert.Nil(t, payload.AuthorizationInfo) + + requestMetadata := payload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, clientIp, requestMetadata.CallerIp) + assert.Equal(t, userAgent, requestMetadata.CallerSuppliedUserAgent) + + // Don't verify explicitly - trust on other unit test + assert.NotNil(t, userAgent, requestMetadata.RequestAttributes) + }) + + t.Run("all attributes set", func(t *testing.T) { + userAgent := "userAgent" + requestHeaders := make(map[string][]string) + requestHeaders["Authorization"] = []string{"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"} + requestHeaders["User-Agent"] = []string{userAgent} + requestHeaders["Custom"] = []string{"customHeader"} + + requestBody := make(map[string]interface{}) + requestBody["key"] = "request" + requestBodyBytes, _ := json.Marshal(requestBody) + request := Request{ + Method: "GET", + URL: RequestUrl{Path: "/audit/new", RawQuery: "topic=project"}, + Host: "localhost:8080", + Proto: "HTTP/1.1", + Scheme: "http", + Header: requestHeaders, + Body: &requestBodyBytes, + } + + clientIp := "127.0.0.1" + correlationId := uuid.NewString() + requestId := uuid.NewString() + requestTime := time.Now().UTC() + auditRequest := AuditRequest{ + Request: &request, + RequestClientIP: clientIp, + RequestCorrelationId: &correlationId, + RequestId: &requestId, + RequestTime: &requestTime, + } + + response := make(map[string]interface{}) + response["key"] = "value" + responseBody, _ := json.Marshal(response) + responseHeader := http.Header{} + responseHeader.Set("Content-Type", "application/json") + responseHeaderMap := make(map[string]string) + responseHeaderMap["Content-Type"] = "application/json" + responseNumItems := int64(1) + responseStatusCode := 400 + responseTime := time.Now().UTC() + + auditResponse := AuditResponse{ + ResponseBodyBytes: &responseBody, + ResponseStatusCode: responseStatusCode, + ResponseHeaders: responseHeader, + ResponseNumItems: &responseNumItems, + ResponseTime: &responseTime, + } + + auditTime := time.Now().UTC() + + objectId := uuid.NewString() + logName := fmt.Sprintf("projects/%s/logs/%s", objectId, EventTypeAdminActivity) + serviceName := "resource-manager" + operationName := fmt.Sprintf("stackit.%s.v2.projects.updated", serviceName) + resourceName := fmt.Sprintf("projects/%s", objectId) + insertId := fmt.Sprintf("%d/%s/%s/%d", auditTime.UnixNano(), "eu01", "1", 1) + permission := "resource-manager.project.edit" + permissionGranted := true + labels := make(map[string]string) + labels["label"] = "value" + + severity := auditV1.LogSeverity_LOG_SEVERITY_DEFAULT + + auditMetadata := AuditMetadata{ + AuditInsertId: insertId, + AuditLabels: &labels, + AuditLogName: logName, + AuditLogSeverity: severity, + AuditOperationName: operationName, + AuditPermission: &permission, + AuditPermissionGranted: &permissionGranted, + AuditResourceName: resourceName, + AuditServiceName: serviceName, + AuditTime: &auditTime, + } + + eventMetadata := map[string]interface{}{"key": "value"} + + traceParent := "traceParent" + traceState := "traceState" + logEntry, _ := NewAuditLogEntry( + auditRequest, + auditResponse, + &eventMetadata, + auditMetadata, + &traceParent, + &traceState) + + assert.Equal(t, logName, logEntry.LogName) + assert.Equal(t, insertId, logEntry.InsertId) + assert.Equal(t, labels, logEntry.Labels) + assert.Equal(t, correlationId, *logEntry.CorrelationId) + assert.Equal(t, timestamppb.New(auditTime), logEntry.Timestamp) + assert.Equal(t, severity, logEntry.Severity) + assert.Equal(t, &traceParent, logEntry.TraceParent) + assert.Equal(t, &traceState, logEntry.TraceState) + assert.NotNil(t, logEntry.ProtoPayload) + + payload := logEntry.ProtoPayload + assert.NotNil(t, payload) + assert.Equal(t, serviceName, payload.ServiceName) + assert.Equal(t, operationName, payload.OperationName) + assert.Equal(t, resourceName, payload.ResourceName) + assert.Equal(t, int32(responseStatusCode), payload.ResponseMetadata.StatusCode.Value) + assert.Equal(t, "Client error", *payload.ResponseMetadata.ErrorMessage) + assert.Nil(t, payload.ResponseMetadata.ErrorDetails) + assert.Equal(t, wrapperspb.Int64(int64(len(responseBody))), payload.ResponseMetadata.ResponseAttributes.Size) + assert.Equal(t, timestamppb.New(responseTime), payload.ResponseMetadata.ResponseAttributes.Time) + assert.Equal(t, wrapperspb.Int64(responseNumItems), payload.ResponseMetadata.ResponseAttributes.NumResponseItems) + assert.Equal(t, responseHeaderMap, payload.ResponseMetadata.ResponseAttributes.Headers) + + expectedRequestBody, _ := structpb.NewStruct(requestBody) + assert.Equal(t, expectedRequestBody, payload.Request) + expectedResponseBody, _ := structpb.NewStruct(response) + assert.Equal(t, expectedResponseBody, payload.Response) + expectedEventMetadata, _ := structpb.NewStruct(eventMetadata) + assert.Equal(t, expectedEventMetadata, payload.Metadata) + + authenticationInfo := payload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Nil(t, authenticationInfo.ServiceAccountName) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + + authorizationInfo := payload.AuthorizationInfo + assert.NotNil(t, authorizationInfo) + assert.Equal(t, 1, len(authorizationInfo)) + assert.Equal(t, permission, *authorizationInfo[0].Permission) + assert.Equal(t, permissionGranted, *authorizationInfo[0].Granted) + assert.Equal(t, resourceName, authorizationInfo[0].Resource) + + requestMetadata := payload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, clientIp, requestMetadata.CallerIp) + assert.Equal(t, userAgent, requestMetadata.CallerSuppliedUserAgent) + + // Don't verify explicitly - trust on other unit test + assert.NotNil(t, userAgent, requestMetadata.RequestAttributes) + }) +} + +func Test_NewInsertId(t *testing.T) { + insertTime := time.Now().UTC() + location := "eu01" + workerId := uuid.NewString() + var eventSequenceNumber uint64 = 1 + + insertId := NewInsertId(insertTime, location, workerId, eventSequenceNumber) + expectedId := fmt.Sprintf("%d/%s/%s/%d", insertTime.UnixNano(), location, workerId, eventSequenceNumber) + assert.Equal(t, expectedId, insertId) +} + +func Test_NewNewAuditRoutingIdentifier(t *testing.T) { + objectId := uuid.NewString() + singularType := SingularTypeProject + + routingIdentifier := NewAuditRoutingIdentifier(objectId, singularType) + assert.Equal(t, objectId, routingIdentifier.Identifier) + assert.Equal(t, singularType, routingIdentifier.Type) +} + +func Test_OperationNameFromUrlPath(t *testing.T) { + + t.Run("empty path", func(t *testing.T) { + operationName := OperationNameFromUrlPath("", "GET") + assert.Equal(t, "", operationName) + }) + + t.Run("root path", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/", "GET") + assert.Equal(t, "", operationName) + }) + + t.Run("path without version", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects", "GET") + assert.Equal(t, "projects.read", operationName) + }) + + t.Run("path with uuid without version", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects/ac51bbd2-cb23-441b-a2ee-5393189695aa", "GET") + assert.Equal(t, "projects.read", operationName) + }) + + t.Run("path with uuid and version", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/v2/projects/ac51bbd2-cb23-441b-a2ee-5393189695aa", "GET") + assert.Equal(t, "v2.projects.read", operationName) + }) + + t.Run("concatenated path", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/v2/organizations/ac51bbd2-cb23-441b-a2ee-5393189695aa/folders/167fc176-9d8e-477b-a56c-b50d7b26adcf/projects/0a2a4f9b-4e67-4562-ad02-c2d200e05aa6/audit/policy", "GET") + assert.Equal(t, "v2.organizations.folders.projects.audit.policy.read", operationName) + }) + + t.Run("path with query params", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/v2/organizations/ac51bbd2-cb23-441b-a2ee-5393189695aa/audit/policy?since=2024-08-27", "GET") + assert.Equal(t, "v2.organizations.audit.policy.read", operationName) + }) + + t.Run("path trailing slash", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects/ac51bbd2-cb23-441b-a2ee-5393189695aa/", "GET") + assert.Equal(t, "projects.read", operationName) + }) + + t.Run("path trailing slash and query params", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects/ac51bbd2-cb23-441b-a2ee-5393189695aa/?changeDate=2024-10-13", "GET") + assert.Equal(t, "projects.read", operationName) + }) + + t.Run("http method post", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects", "POST") + assert.Equal(t, "projects.create", operationName) + }) + + t.Run("http method put", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects", "PUT") + assert.Equal(t, "projects.update", operationName) + }) + + t.Run("http method patch", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects", "PATCH") + assert.Equal(t, "projects.update", operationName) + }) + + t.Run("http method delete", func(t *testing.T) { + operationName := OperationNameFromUrlPath("/projects", "DELETE") + assert.Equal(t, "projects.delete", operationName) + }) +} + +func Test_OperationNameFromGrpcMethod(t *testing.T) { + + t.Run("empty path", func(t *testing.T) { + operationName := OperationNameFromGrpcMethod("") + assert.Equal(t, "", operationName) + }) + + t.Run("root path", func(t *testing.T) { + operationName := OperationNameFromGrpcMethod("/") + assert.Equal(t, "", operationName) + }) + + t.Run("path without version", func(t *testing.T) { + operationName := OperationNameFromGrpcMethod("/example.ExampleService/ManualAuditEvent") + assert.Equal(t, "example.exampleservice.manualauditevent", operationName) + }) + + t.Run("path with version", func(t *testing.T) { + operationName := OperationNameFromGrpcMethod("/example.v1.ExampleService/ManualAuditEvent") + assert.Equal(t, "example.v1.exampleservice.manualauditevent", operationName) + }) + + t.Run("path trailing slash", func(t *testing.T) { + operationName := OperationNameFromGrpcMethod("/example.v1.ExampleService/ManualAuditEvent/") + assert.Equal(t, "example.v1.exampleservice.manualauditevent", operationName) + }) +} + +func Test_GetObjectIdAndTypeFromUrlPath(t *testing.T) { + + t.Run("object id and type not in url", func(t *testing.T) { + objectId, singularType, pluralType, err := GetObjectIdAndTypeFromUrlPath("/v2/projects/audit") + assert.NoError(t, err) + assert.Equal(t, "", objectId) + assert.Nil(t, singularType) + assert.Nil(t, pluralType) + }) + + t.Run("object id and type in url", func(t *testing.T) { + objectId, singularType, pluralType, err := GetObjectIdAndTypeFromUrlPath("/v2/projects/f17d4064-9b65-4334-b6a7-8fed96340124") + assert.NoError(t, err) + assert.Equal(t, "f17d4064-9b65-4334-b6a7-8fed96340124", objectId) + assert.Equal(t, SingularTypeProject, *singularType) + assert.Equal(t, PluralTypeProject, *pluralType) + }) + + t.Run("multiple object ids and types in url", func(t *testing.T) { + objectId, singularType, pluralType, err := GetObjectIdAndTypeFromUrlPath("/v2/organization/8ee58bec-d496-4bb9-af8d-72fda4d78b6b/projects/f17d4064-9b65-4334-b6a7-8fed96340124") + assert.NoError(t, err) + assert.Equal(t, "f17d4064-9b65-4334-b6a7-8fed96340124", objectId) + assert.Equal(t, SingularTypeProject, *singularType) + assert.Equal(t, PluralTypeProject, *pluralType) + }) +} + +func Test_ToArrayMap(t *testing.T) { + + t.Run("empty map", func(t *testing.T) { + result := ToArrayMap(map[string]string{}) + assert.Equal(t, map[string][]string{}, result) + }) + + t.Run("empty map", func(t *testing.T) { + result := ToArrayMap(map[string]string{"key1": "value1", "key2": "value2"}) + assert.Equal(t, map[string][]string{ + "key1": {"value1"}, + "key2": {"value2"}, + }, result) + }) +} + +func Test_StringAttributeFromMetadata(t *testing.T) { + + metadata := map[string][]string{"key1": {"value1"}, "key2": {"value2"}} + + t.Run("not found", func(t *testing.T) { + attribute := StringAttributeFromMetadata(metadata, "key3") + assert.Equal(t, "", attribute) + }) + + t.Run("found", func(t *testing.T) { + attribute := StringAttributeFromMetadata(metadata, "key2") + assert.Equal(t, "value2", attribute) + }) +} diff --git a/audit/utils/sequence_generator.go b/audit/utils/sequence_generator.go new file mode 100644 index 0000000..ebf16f2 --- /dev/null +++ b/audit/utils/sequence_generator.go @@ -0,0 +1,45 @@ +package utils + +import "sync" + +// SequenceNumberGenerator can be used to generate increasing numbers. +type SequenceNumberGenerator interface { + + // Next returns the next number + Next() uint64 + + // Revert can be used to decrease the number (e.g. in case of an error) + Revert() +} + +// DefaultSequenceNumberGenerator is a mutex protected implementation of SequenceNumberGenerator +type DefaultSequenceNumberGenerator struct { + sequenceNumber uint64 + sequenceNumberLock sync.Mutex +} + +// NewDefaultSequenceNumberGenerator returns an instance of DefaultSequenceNumberGenerator as pointer +// of SequenceNumberGenerator. +func NewDefaultSequenceNumberGenerator() *SequenceNumberGenerator { + var generator SequenceNumberGenerator = &DefaultSequenceNumberGenerator{ + sequenceNumber: 0, + sequenceNumberLock: sync.Mutex{}, + } + return &generator +} + +// Next implements SequenceNumberGenerator.Next +func (g *DefaultSequenceNumberGenerator) Next() uint64 { + g.sequenceNumberLock.Lock() + defer g.sequenceNumberLock.Unlock() + next := g.sequenceNumber + g.sequenceNumber += 1 + return next +} + +// Revert implements SequenceNumberGenerator.Revert +func (g *DefaultSequenceNumberGenerator) Revert() { + g.sequenceNumberLock.Lock() + defer g.sequenceNumberLock.Unlock() + g.sequenceNumber -= 1 +} diff --git a/audit/utils/sequence_generator_test.go b/audit/utils/sequence_generator_test.go new file mode 100644 index 0000000..08fa08c --- /dev/null +++ b/audit/utils/sequence_generator_test.go @@ -0,0 +1,22 @@ +package utils + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_DefaultSequenceNumberGenerator(t *testing.T) { + + t.Run("next", func(t *testing.T) { + var sequenceGenerator = NewDefaultSequenceNumberGenerator() + assert.Equal(t, uint64(0), (*sequenceGenerator).Next()) + }) + + t.Run("revert", func(t *testing.T) { + var sequenceGenerator = NewDefaultSequenceNumberGenerator() + assert.Equal(t, uint64(0), (*sequenceGenerator).Next()) + assert.Equal(t, uint64(1), (*sequenceGenerator).Next()) + (*sequenceGenerator).Revert() + assert.Equal(t, uint64(1), (*sequenceGenerator).Next()) + }) +} diff --git a/go.mod b/go.mod index b9e8571..02e7333 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/stretchr/testify v1.9.0 github.com/testcontainers/testcontainers-go v0.33.0 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/trace v1.24.0 google.golang.org/protobuf v1.34.2 ) @@ -58,9 +60,7 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/crypto v0.24.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.26.0 // indirect