mirror of
https://dev.azure.com/schwarzit/schwarzit.stackit-public/_git/audit-go
synced 2026-02-08 00:57:24 +00:00
978 lines
28 KiB
Go
978 lines
28 KiB
Go
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/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 auFdit 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: <idempotency-key>
|
|
// 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: <unix-timestamp>/<region-zone>/<worker-id>/<sequence-number>
|
|
// 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: <pluralType>/<identifier>/logs/<eventType>
|
|
// 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.<product>.<version>.<type-chain>.<operation>
|
|
// 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: <pluralType>/<id>[/locations/<region-zone>][/<details>]
|
|
// 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 "<key>/<id>" 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, 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,
|
|
TraceParent: userProvidedTraceParent,
|
|
TraceState: userProvidedTraceState,
|
|
}
|
|
return &event, nil
|
|
}
|
|
|
|
// GetCalledServiceNameFromRequest extracts the called service name from subdomain name
|
|
func GetCalledServiceNameFromRequest(request *ApiRequest, fallbackName string) string {
|
|
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
|
|
}
|
|
|
|
// 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: <version>-<trace-id>-<parent-id>-<trace-flags>
|
|
// 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 *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: 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 *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 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 *ApiRequest) (
|
|
*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"]
|
|
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/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, 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 := 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|