diff --git a/.github/workflows/sdks-go.yml b/.github/workflows/sdks-go.yml index bf64df9..b80cae6 100644 --- a/.github/workflows/sdks-go.yml +++ b/.github/workflows/sdks-go.yml @@ -48,7 +48,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | pip install pytest - pip install -e sdks/py + pip install -e ../py # --- Build + Vet --- diff --git a/.github/workflows/sdks-py.yml b/.github/workflows/sdks-py.yml index 445f7fc..28e82f3 100644 --- a/.github/workflows/sdks-py.yml +++ b/.github/workflows/sdks-py.yml @@ -42,10 +42,10 @@ jobs: # --- Lint --- - name: Lint (ruff check) - run: ruff check src/ tests/ + run: ruff check src/loza/ tests/ - name: Format check (ruff format) - run: ruff format --check src/ tests/ + run: ruff format --check src/loza/ tests/ # --- Type check (enable when mypy is configured in pyproject.toml) --- # Uncomment the steps below once [tool.mypy] is added to pyproject.toml diff --git a/.github/workflows/sdks-rs.yml b/.github/workflows/sdks-rs.yml index 61fa033..f959a40 100644 --- a/.github/workflows/sdks-rs.yml +++ b/.github/workflows/sdks-rs.yml @@ -102,7 +102,7 @@ jobs: - name: Install conformance dependencies run: | pip install pytest - pip install -e sdks/py + pip install -e ../py - name: Conformance runner working-directory: spec diff --git a/.github/workflows/spec-ci.yml b/.github/workflows/spec-ci.yml index c36b2fc..8e11b37 100644 --- a/.github/workflows/spec-ci.yml +++ b/.github/workflows/spec-ci.yml @@ -51,6 +51,12 @@ jobs: with: go-version-file: sdks/go/go.mod - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install JavaScript SDK dependencies + run: npm ci + working-directory: sdks/js - name: Install conformance dependencies run: python -m pip install --upgrade pip jsonschema pytest - name: Install Python SDK diff --git a/.github/workflows/spec-validate.yml b/.github/workflows/spec-validate.yml index e4edc4f..19d092c 100644 --- a/.github/workflows/spec-validate.yml +++ b/.github/workflows/spec-validate.yml @@ -21,6 +21,15 @@ jobs: with: python-version: '3.11' + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install JavaScript SDK dependencies + run: npm ci + working-directory: sdks/js + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9cb03..b7e71f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to the LOZA project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/0.2.0/). +## [0.3.0] - 2026-08-14 + +### Security + +- **Collector-scoped API keys**: Bearer API keys can now bind directly to one + configured collector with explicit `events:read`, `events:write`, + `events:delete`, or `project:admin` permissions and required environments. + Canonical collector routes reject mismatched collector, environment, and + permission scopes by default. + +### Changed + +- **Authorization configuration**: Documented scoped API-key configuration and + canonical `/collectors/{collector}/...` usage. Legacy unscoped API keys + remain restricted to explicit root-route migration paths. + ## [0.2.6] - 2026-05-30 ### Security diff --git a/README.md b/README.md index 3070601..3a29584 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ License: MIT - Version + Version
@@ -22,11 +22,11 @@ Go SDK CI - - Python SDK CI + + Python SDK CI - - Rust SDK CI + + Rust SDK CI Lozana CI @@ -420,7 +420,7 @@ flowchart TD | Language | Command | | ----------------------- | ------------------------------------------------ | -| Go | `go get github.com/astraive/loza/sdks/go@v0.2.6` | +| Go | `go get github.com/astraive/loza/sdks/go@v0.3.0` | | Python | `pip install loza` | | Rust | `cargo add loza` | | JavaScript / TypeScript | `npm install @astraive/loza` | diff --git a/cli/loza-cli.yaml b/cli/loza-cli.yaml index a15e4a7..a2d2cb0 100644 --- a/cli/loza-cli.yaml +++ b/cli/loza-cli.yaml @@ -1,6 +1,6 @@ name: loza-cli kind: cli -version: 0.2.6 +version: 0.3.0 description: Local operator and developer CLI for LOZA license: MIT repository: https://github.com/Astraive/loza diff --git a/collector/cmd/loza-collector/config.go b/collector/cmd/loza-collector/config.go index 53ec375..64582e6 100644 --- a/collector/cmd/loza-collector/config.go +++ b/collector/cmd/loza-collector/config.go @@ -18,8 +18,9 @@ import ( ) var ( - configIdentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - configPathPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) + configIdentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + configPathPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) + collectorSlugPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,62})?$`) ) type fileConfig = collectorconfig.Config @@ -565,34 +566,52 @@ func resolveAuthConfig(fc fileConfig) (string, []collectorAuthKey, error) { default: return "", nil, fmt.Errorf("auth.keys[%d].kind must be pub or sec", i) } - if kind == auth.KeyKindPublic { - hasOrigin := false - for _, origin := range key.AllowedOrigins { - if strings.TrimSpace(origin) != "" { - hasOrigin = true - break - } - } - if !hasOrigin { - return "", nil, fmt.Errorf("auth.keys[%d].allowed_origins must not be empty for public keys", i) + if kind == auth.KeyKindPublic && !hasNonEmptyString(key.AllowedOrigins) { + return "", nil, fmt.Errorf("auth.keys[%d].allowed_origins must not be empty for public keys", i) + } + + mode, explicitMode, err := resolveCredentialMode(key.Mode, kind, fmt.Sprintf("auth.keys[%d].mode", i)) + if err != nil { + return "", nil, err + } + var roles []auth.Role + if len(key.Roles) > 0 || explicitMode { + roles, err = resolveCredentialRoles(key.Roles, mode, explicitMode, fmt.Sprintf("auth.keys[%d].roles", i)) + if err != nil { + return "", nil, err } } - roles := make([]auth.Role, len(key.Roles)) - for j, rawRole := range key.Roles { - role := auth.Role(strings.TrimSpace(rawRole)) - if !isKnownAuthRole(role) { - return "", nil, fmt.Errorf("auth.keys[%d].roles[%d] %q is not recognized", i, j, rawRole) + collector := strings.TrimSpace(key.Collector) + if (collector == "") != (len(key.Permissions) == 0) { + return "", nil, fmt.Errorf("auth.keys[%d].collector and auth.keys[%d].permissions must be configured together", i, i) + } + var permissions []auth.Permission + allowedEnvs := append([]string(nil), key.AllowedEnvs...) + if collector != "" { + if !collectorSlugPattern.MatchString(collector) { + return "", nil, fmt.Errorf("auth.keys[%d].collector must be a valid collector slug", i) + } + permissions, err = resolveCollectorKeyPermissions(key.Permissions, i) + if err != nil { + return "", nil, err + } + allowedEnvs, err = resolveCollectorKeyEnvironments(key.AllowedEnvs, i) + if err != nil { + return "", nil, err } - roles[j] = role } + keys = append(keys, collectorAuthKey{ name: strings.TrimSpace(key.Name), keyID: keyID, secret: secret, kind: kind, + mode: mode, roles: roles, - allowedEnvs: append([]string(nil), key.AllowedEnvs...), + collector: collector, + permissions: permissions, + allowedEnvs: allowedEnvs, allowedServices: append([]string(nil), key.AllowedServices...), allowedOrigins: append([]string(nil), key.AllowedOrigins...), allowedIPs: append([]string(nil), key.AllowedIPs...), @@ -604,6 +623,272 @@ func resolveAuthConfig(fc fileConfig) (string, []collectorAuthKey, error) { return serverSecret, keys, nil } +func resolveAuthTokens(fc fileConfig) ([]collectorAuthToken, error) { + tokens := make([]collectorAuthToken, 0, len(fc.Auth.Tokens)) + seenTokenEnvs := make(map[string]struct{}, len(fc.Auth.Tokens)) + for i, configured := range fc.Auth.Tokens { + tokenEnv := strings.TrimSpace(configured.TokenEnv) + if !configIdentPattern.MatchString(tokenEnv) { + return nil, fmt.Errorf("auth.tokens[%d].token_env must name an environment variable", i) + } + if _, exists := seenTokenEnvs[tokenEnv]; exists { + return nil, fmt.Errorf("auth.tokens[%d].token_env %q must be unique", i, tokenEnv) + } + seenTokenEnvs[tokenEnv] = struct{}{} + token := strings.TrimSpace(os.Getenv(tokenEnv)) + if token == "" { + return nil, fmt.Errorf("auth.tokens[%d].token_env %q did not resolve to a non-empty value", i, tokenEnv) + } + if _, err := auth.ParseKey(token); err == nil { + return nil, fmt.Errorf("auth.tokens[%d].token_env must not contain a structured LOZA API key", i) + } + + mode, _, err := resolveCredentialMode(configured.Mode, auth.KeyKindToken, fmt.Sprintf("auth.tokens[%d].mode", i)) + if err != nil { + return nil, err + } + roles, err := resolveCredentialRoles(configured.Roles, mode, true, fmt.Sprintf("auth.tokens[%d].roles", i)) + if err != nil { + return nil, err + } + if mode == auth.ModePublic && !hasNonEmptyString(configured.AllowedOrigins) { + return nil, fmt.Errorf("auth.tokens[%d].allowed_origins must not be empty for public credentials", i) + } + + collector := strings.TrimSpace(configured.Collector) + if (collector == "") != (len(configured.Permissions) == 0) { + return nil, fmt.Errorf("auth.tokens[%d].collector and auth.tokens[%d].permissions must be configured together", i, i) + } + var permissions []auth.Permission + allowedEnvs := append([]string(nil), configured.AllowedEnvs...) + if collector != "" { + if !collectorSlugPattern.MatchString(collector) { + return nil, fmt.Errorf("auth.tokens[%d].collector must be a valid collector slug", i) + } + permissions, err = resolveCollectorPermissions(configured.Permissions, fmt.Sprintf("auth.tokens[%d].permissions", i)) + if err != nil { + return nil, err + } + allowedEnvs, err = resolveCollectorEnvironments(configured.AllowedEnvs, fmt.Sprintf("auth.tokens[%d].allowed_envs", i)) + if err != nil { + return nil, err + } + } + tokens = append(tokens, collectorAuthToken{ + name: strings.TrimSpace(configured.Name), + token: token, + mode: mode, + roles: roles, + collector: collector, + permissions: permissions, + allowedEnvs: allowedEnvs, + allowedServices: trimStrings(configured.AllowedServices), + allowedOrigins: trimStrings(configured.AllowedOrigins), + allowedIPs: trimStrings(configured.AllowedIPs), + maxPayloadBytes: configured.MaxPayloadBytes, + maxRequestsPerMinute: configured.MaxRequestsPerMinute, + maxEventsPerMinute: configured.MaxEventsPerMinute, + }) + } + return tokens, nil +} + +func resolveCollectorAuthGrants(fc fileConfig) ([]string, []collectorAuthGrant, error) { + collectors := make([]string, 0, len(fc.Auth.Collectors)) + knownCollectors := make(map[string]struct{}, len(fc.Auth.Collectors)) + for i, configured := range fc.Auth.Collectors { + slug := configured.Slug + if !collectorSlugPattern.MatchString(slug) { + return nil, nil, fmt.Errorf("auth.collectors[%d].slug must be a valid collector slug", i) + } + if _, exists := knownCollectors[slug]; exists { + return nil, nil, fmt.Errorf("auth.collectors[%d].slug duplicates a configured collector", i) + } + knownCollectors[slug] = struct{}{} + collectors = append(collectors, slug) + } + + defaultCollector := fc.Auth.DefaultCollector + if defaultCollector != "" { + if _, exists := knownCollectors[defaultCollector]; !exists { + return nil, nil, errors.New("auth.default_collector must name a configured collector") + } + } + + grants := make([]collectorAuthGrant, 0, len(fc.Auth.Grants)) + seenCredentialIDs := make(map[string]struct{}, len(fc.Auth.Grants)) + for i, configured := range fc.Auth.Grants { + collector := configured.Collector + if _, exists := knownCollectors[collector]; !exists { + return nil, nil, fmt.Errorf("auth.grants[%d].collector must name a configured collector", i) + } + + username := configured.Username + publicIDEnv := strings.TrimSpace(configured.PublicIDEnv) + if username != "" && publicIDEnv != "" { + return nil, nil, fmt.Errorf("auth.grants[%d] must configure either username/password_env or public_id_env", i) + } + + var keyID, secret string + var kind auth.KeyKind + switch { + case publicIDEnv != "": + if configured.PasswordEnv != "" { + return nil, nil, fmt.Errorf("auth.grants[%d].password_env is not permitted for public grants", i) + } + if !configIdentPattern.MatchString(publicIDEnv) { + return nil, nil, fmt.Errorf("auth.grants[%d].public_id_env must name an environment variable", i) + } + publicID := strings.TrimSpace(os.Getenv(publicIDEnv)) + if !auth.IsPublicAccessID(publicID) { + return nil, nil, fmt.Errorf("auth.grants[%d].public_id_env did not resolve to a valid public access ID", i) + } + keyID = publicID + kind = auth.KeyKindPublic + case username != "": + keyID = username + if !validConfigBasicUsername(username) { + return nil, nil, fmt.Errorf("auth.grants[%d].username is invalid", i) + } + passwordEnv := strings.TrimSpace(configured.PasswordEnv) + if !configIdentPattern.MatchString(passwordEnv) { + return nil, nil, fmt.Errorf("auth.grants[%d].password_env must name an environment variable", i) + } + secret = strings.TrimSpace(os.Getenv(passwordEnv)) + if secret == "" { + return nil, nil, fmt.Errorf("auth.grants[%d].password_env did not resolve to a non-empty value", i) + } + kind = auth.KeyKindSecret + default: + return nil, nil, fmt.Errorf("auth.grants[%d] must configure username/password_env or public_id", i) + } + if _, exists := seenCredentialIDs[keyID]; exists { + return nil, nil, errors.New("configured credential identifiers must be unique") + } + seenCredentialIDs[keyID] = struct{}{} + + permissions, err := resolveCollectorGrantPermissions(configured.Permissions, i) + if err != nil { + return nil, nil, err + } + environments, err := resolveCollectorGrantEnvironments(configured.AllowedEnvs, i) + if err != nil { + return nil, nil, err + } + if kind == auth.KeyKindPublic && !hasNonEmptyString(configured.AllowedOrigins) { + return nil, nil, fmt.Errorf("auth.grants[%d].allowed_origins must not be empty for public grants", i) + } + + grants = append(grants, collectorAuthGrant{ + name: strings.TrimSpace(configured.Name), + collector: collector, + keyID: keyID, + secret: secret, + kind: kind, + permissions: permissions, + allowedEnvs: environments, + allowedServices: trimStrings(configured.AllowedServices), + allowedOrigins: trimStrings(configured.AllowedOrigins), + allowedIPs: trimStrings(configured.AllowedIPs), + maxPayloadBytes: configured.MaxPayloadBytes, + maxRequestsPerMinute: configured.MaxRequestsPerMinute, + maxEventsPerMinute: configured.MaxEventsPerMinute, + }) + } + return collectors, grants, nil +} + +func validConfigBasicUsername(username string) bool { + if username == "" || strings.TrimSpace(username) != username || strings.Contains(username, ":") { + return false + } + for _, r := range username { + if r == ' ' || r == '\t' || r == '\r' || r == '\n' { + return false + } + } + return true +} + +func resolveCollectorGrantPermissions(raw []string, grantIndex int) ([]auth.Permission, error) { + return resolveCollectorPermissions(raw, fmt.Sprintf("auth.grants[%d].permissions", grantIndex)) +} + +func resolveCollectorKeyPermissions(raw []string, keyIndex int) ([]auth.Permission, error) { + return resolveCollectorPermissions(raw, fmt.Sprintf("auth.keys[%d].permissions", keyIndex)) +} + +func resolveCollectorPermissions(raw []string, field string) ([]auth.Permission, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must not be empty", field) + } + permissions := make([]auth.Permission, 0, len(raw)) + seen := make(map[auth.Permission]struct{}, len(raw)) + for permissionIndex, value := range raw { + permission := auth.Permission(strings.TrimSpace(value)) + switch permission { + case auth.PermEventsRead, auth.PermEventsWrite, auth.PermEventsDelete, + auth.PermLogsRead, auth.PermLogsWrite, auth.PermLogsEdit, auth.PermLogsDelete, + auth.PermProjectAdmin: + default: + return nil, fmt.Errorf("%s[%d] is not a collector grant permission", field, permissionIndex) + } + if _, exists := seen[permission]; exists { + return nil, fmt.Errorf("%s must not contain duplicates", field) + } + seen[permission] = struct{}{} + permissions = append(permissions, permission) + } + return permissions, nil +} + +func resolveCollectorGrantEnvironments(raw []string, grantIndex int) ([]string, error) { + return resolveCollectorEnvironments(raw, fmt.Sprintf("auth.grants[%d].allowed_envs", grantIndex)) +} + +func resolveCollectorKeyEnvironments(raw []string, keyIndex int) ([]string, error) { + return resolveCollectorEnvironments(raw, fmt.Sprintf("auth.keys[%d].allowed_envs", keyIndex)) +} + +func resolveCollectorEnvironments(raw []string, field string) ([]string, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must not be empty", field) + } + environments := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for environmentIndex, value := range raw { + environment := strings.TrimSpace(value) + if environment == "" { + return nil, fmt.Errorf("%s[%d] must not be empty", field, environmentIndex) + } + if _, exists := seen[environment]; exists { + return nil, fmt.Errorf("%s must not contain duplicates", field) + } + seen[environment] = struct{}{} + environments = append(environments, environment) + } + return environments, nil +} + +func hasNonEmptyString(values []string) bool { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func trimStrings(values []string) []string { + trimmed := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + trimmed = append(trimmed, value) + } + } + return trimmed +} + func resolveConfigSecret(field, raw string) (string, error) { value := strings.TrimSpace(raw) if value == "" { @@ -625,14 +910,56 @@ func resolveConfigSecret(field, raw string) (string, error) { return value, nil } -func isKnownAuthRole(role auth.Role) bool { - switch role { - case auth.RoleIngestPublic, auth.RoleIngestServer, auth.RoleIngestEnterprise, - auth.RoleProjectReadonly, auth.RoleProjectOperator, auth.RoleProjectAdmin: - return true +func resolveCredentialMode(raw string, kind auth.KeyKind, field string) (auth.AccessMode, bool, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + if kind == auth.KeyKindPublic { + return auth.ModePublic, false, nil + } + return auth.ModePrivate, false, nil + } + + mode := auth.AccessMode(value) + switch mode { + case auth.ModePublic: + if kind == auth.KeyKindSecret { + return "", false, fmt.Errorf("%s public mode requires a public key or token", field) + } + case auth.ModePrivate: + if kind == auth.KeyKindPublic { + return "", false, fmt.Errorf("%s private mode requires a secret key or token", field) + } default: - return false + return "", false, fmt.Errorf("%s must be public or private", field) + } + return mode, true, nil +} + +func resolveCredentialRoles(raw []string, mode auth.AccessMode, enforceMode bool, field string) ([]auth.Role, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must not be empty", field) + } + roles := make([]auth.Role, len(raw)) + seen := make(map[auth.Role]struct{}, len(raw)) + for i, rawRole := range raw { + role := auth.Role(strings.TrimSpace(rawRole)) + if !auth.IsKnownRole(role) { + return nil, fmt.Errorf("%s[%d] %q is not recognized", field, i, rawRole) + } + if _, exists := seen[role]; exists { + return nil, fmt.Errorf("%s must not contain duplicates", field) + } + if enforceMode && !auth.RoleAllowedInMode(role, mode) { + return nil, fmt.Errorf("%s[%d] is not allowed for public credentials", field, i) + } + seen[role] = struct{}{} + roles[i] = role } + return roles, nil +} + +func isKnownAuthRole(role auth.Role) bool { + return auth.IsKnownRole(role) } func validateFileConfig(fc fileConfig) error { @@ -654,10 +981,55 @@ func validateFileConfig(fc fileConfig) error { if strings.TrimSpace(fc.Auth.Header) == "" { return errors.New("auth.header must not be empty") } - authServerSecret, _, err := resolveAuthConfig(fc) + authServerSecret, authKeys, err := resolveAuthConfig(fc) + if err != nil { + return err + } + authTokens, err := resolveAuthTokens(fc) + if err != nil { + return err + } + authCollectors, authGrants, err := resolveCollectorAuthGrants(fc) if err != nil { return err } + configuredCollectors := make(map[string]struct{}, len(authCollectors)) + for _, collector := range authCollectors { + configuredCollectors[collector] = struct{}{} + } + for keyIndex, key := range authKeys { + if key.collector == "" { + continue + } + if _, exists := configuredCollectors[key.collector]; !exists { + return fmt.Errorf("auth.keys[%d].collector must name a configured collector", keyIndex) + } + } + for tokenIndex, token := range authTokens { + if token.collector == "" { + continue + } + if _, exists := configuredCollectors[token.collector]; !exists { + return fmt.Errorf("auth.tokens[%d].collector must name a configured collector", tokenIndex) + } + } + configuredCredentialIDs := make(map[string]struct{}, len(authKeys)+len(authGrants)+len(authTokens)) + for _, key := range authKeys { + configuredCredentialIDs[key.keyID] = struct{}{} + } + for _, grant := range authGrants { + if _, exists := configuredCredentialIDs[grant.keyID]; exists { + return errors.New("configured credential identifiers must be unique") + } + configuredCredentialIDs[grant.keyID] = struct{}{} + } + for _, token := range authTokens { + tokenID := auth.TokenLookupID(token.token, []byte(authServerSecret)) + if _, exists := configuredCredentialIDs[tokenID]; exists { + return errors.New("configured credential identifiers must be unique") + } + configuredCredentialIDs[tokenID] = struct{}{} + } if fc.Auth.Enabled { if authServerSecret == "" { return errors.New("auth.enabled requires auth.server_secret") @@ -673,8 +1045,8 @@ func validateFileConfig(fc fileConfig) error { return fmt.Errorf("auth.value must be a valid LOZA API key: %w", err) } } - if len(fc.Auth.Keys) == 0 && strings.TrimSpace(fc.Auth.Value) == "" { - return errors.New("auth enabled but no configured key or valid legacy API key") + if len(fc.Auth.Keys) == 0 && len(fc.Auth.Grants) == 0 && len(fc.Auth.Tokens) == 0 && strings.TrimSpace(fc.Auth.Value) == "" { + return errors.New("auth enabled but no configured grant, key, token, or valid legacy API key") } } if strings.TrimSpace(fc.Storage.EncryptionKey) == "" { @@ -1057,10 +1429,18 @@ func validateComponentRegistry(reg collectorconfig.ComponentRegistryConfig) erro func runtimeConfigFromFile(fc fileConfig) collectorConfig { authServerSecret, authKeys, _ := resolveAuthConfig(fc) + authTokens, _ := resolveAuthTokens(fc) + authCollectors, authGrants, _ := resolveCollectorAuthGrants(fc) + authDefaultCollector := strings.TrimSpace(fc.Auth.DefaultCollector) + if len(authCollectors) == 0 { + // Configurations created before collector-scoped routes used the root + // API. Bind those legacy routes to an implicit default scope so they + // retain their documented behavior while keeping ownership explicit. + authDefaultCollector = "default" + } return collectorConfig{ addr: fc.Collector.Addr, readHeaderTimeout: fc.Collector.ReadHeaderTimeout, - shutdownTimeout: fc.Collector.ShutdownTimeout, maxBodyBytes: fc.Collector.MaxBodyBytes, maxEventsPerRequest: fc.Collector.MaxEventsPerReq, serverConfig: serverConfig{ @@ -1112,14 +1492,18 @@ func runtimeConfigFromFile(fc fileConfig) collectorConfig { BatchLimit: fc.Collector.Server.GraphQL.BatchLimit, }, }, - authEnabled: fc.Auth.Enabled, - authAllowLocalDevKeys: fc.Auth.AllowLocalDevKeys, - authServerSecret: authServerSecret, - authCacheTTL: fc.Auth.CacheTTL, - authNegativeCacheTTL: fc.Auth.NegativeCacheTTL, - authKeys: authKeys, - apiKeyHeader: fc.Auth.Header, - apiKey: fc.Auth.Value, + authEnabled: fc.Auth.Enabled, + authAllowLocalDevKeys: fc.Auth.AllowLocalDevKeys, + authServerSecret: authServerSecret, + authCacheTTL: fc.Auth.CacheTTL, + authNegativeCacheTTL: fc.Auth.NegativeCacheTTL, + authDefaultCollector: authDefaultCollector, + authCollectors: authCollectors, + authGrants: authGrants, + authKeys: authKeys, + authTokens: authTokens, + apiKeyHeader: fc.Auth.Header, + apiKey: fc.Auth.Value, rateLimitEnabled: fc.RateLimit.Enabled, rateLimitRPS: fc.RateLimit.RPS, rateLimitBurst: fc.RateLimit.Burst, @@ -1359,10 +1743,12 @@ func parseConfigBool(raw string) (bool, error) { } func marshalPrintableConfig(fc fileConfig) ([]byte, error) { + redacted := fc + var out bytes.Buffer enc := yaml.NewEncoder(&out) enc.SetIndent(2) - if err := enc.Encode(fc); err != nil { + if err := enc.Encode(redacted); err != nil { _ = enc.Close() return nil, err } @@ -1371,4 +1757,3 @@ func marshalPrintableConfig(fc fileConfig) ([]byte, error) { } return out.Bytes(), nil } - diff --git a/collector/cmd/loza-collector/config_test.go b/collector/cmd/loza-collector/config_test.go index cc43962..b842385 100644 --- a/collector/cmd/loza-collector/config_test.go +++ b/collector/cmd/loza-collector/config_test.go @@ -16,9 +16,9 @@ import ( func TestMain(m *testing.M) { for key, value := range map[string]string{ "COLLECTOR_AUTH_SERVER_SECRET": "test-auth-server-secret", - "COLLECTOR_INGEST_KEY_SECRET": "test-ingest-key-secret", - "COLLECTOR_ADMIN_KEY_SECRET": "test-admin-key-secret", - "LOZA_STORAGE_ENCRYPTION_KEY": "test-storage-encryption-key", + "COLLECTOR_INGEST_KEY_SECRET": "test-ingest-key-secret", + "COLLECTOR_ADMIN_KEY_SECRET": "test-admin-key-secret", + "LOZA_STORAGE_ENCRYPTION_KEY": "test-storage-encryption-key", } { if err := os.Setenv(key, value); err != nil { panic(err) @@ -159,6 +159,159 @@ storage: if cfg.authKeys[0].secret != "test-ingest-key-secret" || cfg.authKeys[1].kind != auth.KeyKindSecret { t.Fatalf("configured key secrets or kind were not resolved") } + if cfg.authDefaultCollector != "default" { + t.Fatalf("legacy root routes must retain the implicit default collector, got %q", cfg.authDefaultCollector) + } +} + +func TestLoadCollectorConfigFromArgsResolvesScopedConfiguredKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "collector.yaml") + raw := ` +auth: + enabled: true + server_secret: ${COLLECTOR_AUTH_SERVER_SECRET} + cache_ttl: 1m + negative_cache_ttl: 10s + collectors: + - slug: checkout + keys: + - name: checkout-writer + key_id: kcheckoutwriter + secret_env: COLLECTOR_INGEST_KEY_SECRET + kind: sec + collector: checkout + permissions: [events:write] + allowed_envs: [prod] +storage: + encryption_key_env: LOZA_STORAGE_ENCRYPTION_KEY +` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := loadCollectorConfigFromArgs([]string{"-c", path}) + if err != nil { + t.Fatalf("load scoped key configuration: %v", err) + } + if len(cfg.authKeys) != 1 || cfg.authKeys[0].collector != "checkout" { + t.Fatalf("scoped key was not carried to runtime: %+v", cfg.authKeys) + } + if got := cfg.authKeys[0].permissions; len(got) != 1 || got[0] != auth.PermEventsWrite { + t.Fatalf("scoped key permissions = %v, want events:write", got) + } + if len(cfg.authCollectors) != 1 || cfg.authCollectors[0] != "checkout" { + t.Fatalf("configured collector was not carried to runtime: %v", cfg.authCollectors) + } +} + +func TestResolveCollectorAuthGrantsBuildsPrivateAndPublicCredentials(t *testing.T) { + publicID := "lx_pub_0123456789abcdefghijklmnopqrstuv" + t.Setenv("COLLECTOR_PRIVATE_GRANT_PASSWORD", "private-grant-password") + t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", publicID) + cfg := validFileConfig() + cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "browser-events"}} + cfg.Auth.DefaultCollector = "browser-events" + cfg.Auth.Grants = []collectorconfig.AuthGrantConfig{ + { + Name: "service", + Collector: "browser-events", + Username: "service-writer", + PasswordEnv: "COLLECTOR_PRIVATE_GRANT_PASSWORD", + Permissions: []string{"events:write"}, + AllowedEnvs: []string{"prod"}, + }, + { + Name: "browser", + Collector: "browser-events", + PublicIDEnv: "COLLECTOR_PUBLIC_ACCESS_ID", + Permissions: []string{"events:write"}, + AllowedEnvs: []string{"prod"}, + AllowedOrigins: []string{"https://console.example.test"}, + }, + } + + collectors, grants, err := resolveCollectorAuthGrants(cfg) + if err != nil { + t.Fatalf("resolve grants: %v", err) + } + if len(collectors) != 1 || collectors[0] != "browser-events" || len(grants) != 2 { + t.Fatalf("unexpected resolved grants: collectors=%v grants=%+v", collectors, grants) + } + if grants[0].kind != auth.KeyKindSecret || grants[0].secret != "private-grant-password" || grants[0].keyID != "service-writer" { + t.Fatalf("private grant was not resolved: %+v", grants[0]) + } + if grants[1].kind != auth.KeyKindPublic || grants[1].secret != "" || grants[1].keyID != publicID { + t.Fatalf("public grant was not constructed as a passwordless capability: %+v", grants[1]) + } +} + +func TestResolveAuthConfigBuildsCollectorScopedKey(t *testing.T) { + cfg := validFileConfig() + cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "orders"}} + cfg.Auth.Keys = []collectorconfig.AuthKeyConfig{{ + Name: "orders-operator", + KeyID: "korders", + SecretEnv: "COLLECTOR_INGEST_KEY_SECRET", + Kind: "sec", + Collector: "orders", + Permissions: []string{"events:read", "events:write", "events:delete"}, + AllowedEnvs: []string{"production"}, + }} + + _, keys, err := resolveAuthConfig(cfg) + if err != nil { + t.Fatalf("resolve scoped key: %v", err) + } + if len(keys) != 1 || keys[0].collector != "orders" { + t.Fatalf("scoped key collector was not resolved: %+v", keys) + } + if got := keys[0].permissions; len(got) != 3 || got[0] != auth.PermEventsRead || got[1] != auth.PermEventsWrite || got[2] != auth.PermEventsDelete { + t.Fatalf("scoped key permissions = %v, want read/write/delete", got) + } + + cfg.Auth.Keys[0].Permissions = nil + if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "auth.keys[0].permissions") { + t.Fatalf("expected scoped key permissions rejection, got %v", err) + } + cfg.Auth.Keys[0].Permissions = []string{"events:write"} + cfg.Auth.Keys[0].Collector = "unknown" + if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "configured collector") { + t.Fatalf("expected unknown scoped key collector rejection, got %v", err) + } +} + +func TestValidateFileConfigRejectsInvalidCollectorGrantConfiguration(t *testing.T) { + publicID := "lx_pub_0123456789abcdefghijklmnopqrstuv" + t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", publicID) + cfg := validFileConfig() + cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "web"}, {Slug: "web"}} + if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("expected duplicate collector rejection, got %v", err) + } + + cfg = validFileConfig() + cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "Not_valid"}} + if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "valid collector slug") { + t.Fatalf("expected invalid collector slug rejection, got %v", err) + } + + cfg = validFileConfig() + cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "web"}} + cfg.Auth.Grants = []collectorconfig.AuthGrantConfig{{ + Collector: "unknown", + PublicIDEnv: "COLLECTOR_PUBLIC_ACCESS_ID", + Permissions: []string{"events:write"}, + AllowedEnvs: []string{"prod"}, + AllowedOrigins: []string{"https://console.example.test"}, + }} + if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "configured collector") { + t.Fatalf("expected unknown collector rejection, got %v", err) + } + + cfg.Auth.Grants[0].Collector = "web" + t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", "lx_pub_invalid") + if err := validateFileConfig(cfg); err == nil || strings.Contains(err.Error(), "lx_pub_invalid") { + t.Fatalf("expected redacted malformed public ID rejection, got %v", err) + } } func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) { @@ -170,6 +323,7 @@ func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) { cfg.Storage.EncryptionKey = "test-storage-encryption-key" cfg.Auth.Keys = []collectorconfig.AuthKeyConfig{ {KeyID: "duplicate", SecretEnv: "COLLECTOR_INGEST_KEY_SECRET", Kind: "sec", Roles: []string{"collector_ingest_server"}}, + {KeyID: "duplicate", SecretEnv: "COLLECTOR_ADMIN_KEY_SECRET", Kind: "sec", Roles: []string{"not-a-role"}}, } if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "must be unique") { @@ -193,6 +347,37 @@ func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) { } } +func TestResolveAuthTokensBuildsRBACCredentials(t *testing.T) { + t.Setenv("COLLECTOR_ADMIN_TOKEN", "lxt_private_admin_token") + cfg := validFileConfig() + cfg.Auth.Tokens = []collectorconfig.AuthTokenConfig{{ + Name: "admin-token", + TokenEnv: "COLLECTOR_ADMIN_TOKEN", + Mode: "private", + Roles: []string{"client"}, + Collector: "logs", + Permissions: []string{"logs:write"}, + AllowedEnvs: []string{"prod"}, + }} + + tokens, err := resolveAuthTokens(cfg) + if err != nil { + t.Fatalf("resolve token: %v", err) + } + if len(tokens) != 1 || tokens[0].mode != auth.ModePrivate || len(tokens[0].roles) != 1 || tokens[0].roles[0] != auth.RoleClient { + t.Fatalf("token RBAC configuration was not resolved: %+v", tokens) + } + if len(tokens[0].permissions) != 1 || tokens[0].permissions[0] != auth.PermLogsWrite { + t.Fatalf("token log scope was not resolved: %+v", tokens[0]) + } + + cfg.Auth.Tokens[0].Roles = []string{"admin"} + cfg.Auth.Tokens[0].Mode = "public" + if _, err := resolveAuthTokens(cfg); err == nil || !strings.Contains(err.Error(), "public credentials") { + t.Fatalf("expected privileged public token rejection, got %v", err) + } +} + func TestValidateFileConfigRejectsInvalidValues(t *testing.T) { cfg := validFileConfig() cfg.DuckDB.Table = "events;drop" diff --git a/collector/cmd/loza-collector/config_types.go b/collector/cmd/loza-collector/config_types.go index 2d0abcf..94b44ff 100644 --- a/collector/cmd/loza-collector/config_types.go +++ b/collector/cmd/loza-collector/config_types.go @@ -30,7 +30,44 @@ type collectorAuthKey struct { keyID string secret string kind auth.KeyKind + mode auth.AccessMode roles []auth.Role + collector string + permissions []auth.Permission + allowedEnvs []string + allowedServices []string + allowedOrigins []string + allowedIPs []string + maxPayloadBytes int + maxRequestsPerMinute int + maxEventsPerMinute int +} + +// collectorAuthGrant is the resolved runtime representation of a +// collector-bound Basic credential. +type collectorAuthGrant struct { + name string + collector string + keyID string + secret string + kind auth.KeyKind + permissions []auth.Permission + allowedEnvs []string + allowedServices []string + allowedOrigins []string + allowedIPs []string + maxPayloadBytes int + maxRequestsPerMinute int + maxEventsPerMinute int +} + +type collectorAuthToken struct { + name string + token string + mode auth.AccessMode + roles []auth.Role + collector string + permissions []auth.Permission allowedEnvs []string allowedServices []string allowedOrigins []string @@ -55,6 +92,10 @@ type collectorConfig struct { authCacheTTL time.Duration authNegativeCacheTTL time.Duration authKeys []collectorAuthKey + authTokens []collectorAuthToken + authDefaultCollector string + authCollectors []string + authGrants []collectorAuthGrant apiKeyHeader string apiKey string rateLimitEnabled bool diff --git a/collector/cmd/loza-collector/control_handlers.go b/collector/cmd/loza-collector/control_handlers.go index d56c999..7700a7d 100644 --- a/collector/cmd/loza-collector/control_handlers.go +++ b/collector/cmd/loza-collector/control_handlers.go @@ -13,6 +13,7 @@ import ( "strings" "time" + publichttp "github.com/astraive/loza/collector/server/http" "github.com/rs/zerolog/log" ) @@ -94,6 +95,9 @@ func (s *collectorState) handleSinkTest(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } name := r.PathValue("name") sink, ok := s.findSinkByName(name) if !ok { @@ -159,6 +163,10 @@ func (s *collectorState) handleQuery(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if isCanonicalCollectorRoute(r) { + s.handleScopedQuery(w, r) + return + } requestID := fmt.Sprintf("q_%d", time.Now().UTC().UnixNano()) var req queryRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { @@ -267,11 +275,96 @@ func (s *collectorState) handleQuery(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"columns": columns, "rows": result, "row_count": len(result)}) } +// handleScopedQuery exposes a fixed, parameterized event-listing query. Raw +// client SQL cannot prove collector ownership and is deliberately unavailable +// on canonical collector routes. +func (s *collectorState) handleScopedQuery(w http.ResponseWriter, r *http.Request) { + scope, ok := publichttp.AuthorizedCollectorFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "collector_scope_required"}) + return + } + var req queryRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_query_request", "message": err.Error()}) + return + } + if strings.TrimSpace(req.Query) != "" || strings.TrimSpace(req.SQL) != "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "scoped_raw_sql_unsupported"}) + return + } + if req.Limit <= 0 || req.Limit > 1000 { + req.Limit = 1000 + } + table, err := quoteSQLIdent(s.cfg.duckDBTable) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "query_unavailable"}) + return + } + db := s.queryDB + var closeDB func() + if db == nil { + db, err = sql.Open(s.cfg.duckDBDriver, s.cfg.duckDBPath) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "query_unavailable"}) + return + } + closeDB = func() { _ = db.Close() } + } + if closeDB != nil { + defer closeDB() + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? AND %s = ? LIMIT ?", table, collectorOwnershipColumn, environmentOwnershipColumn) + rows, err := db.QueryContext(ctx, query, scope.Name, scope.Environment, req.Limit) + if err != nil { + log.Error().Err(err).Msg("scoped query failed") + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "query_failed"}) + return + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"}) + return + } + result := make([]map[string]any, 0) + for rows.Next() { + values := make([]any, len(columns)) + ptrs := make([]any, len(columns)) + for i := range values { + ptrs[i] = &values[i] + } + if err := rows.Scan(ptrs...); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"}) + return + } + row := make(map[string]any, len(columns)) + for i, column := range columns { + if value, isBytes := values[i].([]byte); isBytes { + row[column] = string(value) + } else { + row[column] = values[i] + } + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"columns": columns, "rows": result, "row_count": len(result)}) +} + func (s *collectorState) handleReplay(w http.ResponseWriter, r *http.Request) { if !s.isAuthorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } var req struct { Events [][]byte `json:"events"` Filter string `json:"filter"` @@ -302,6 +395,9 @@ func (s *collectorState) handleDLQList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } events, err := s.readDLQRecords() if err != nil { writeJSON(w, http.StatusOK, map[string]any{"events": []any{}, "error": err.Error()}) @@ -315,6 +411,9 @@ func (s *collectorState) handleDLQShow(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } id := r.PathValue("id") events, err := s.readDLQRecords() if err != nil { @@ -335,6 +434,9 @@ func (s *collectorState) handleDLQReplay(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } id := r.PathValue("id") events, err := s.readDLQRecords() if err != nil { @@ -364,6 +466,9 @@ func (s *collectorState) handleDLQReplayAll(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } events, err := s.readDLQRecords() if err != nil { writeJSON(w, http.StatusOK, map[string]any{"accepted": 0, "replayed": 0, "error": err.Error()}) @@ -392,6 +497,9 @@ func (s *collectorState) handleDLQDelete(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"}) return } + if rejectUnsupportedScopedOperation(w, r) { + return + } id := r.PathValue("id") events, err := s.readDLQRecords() if err != nil { diff --git a/collector/cmd/loza-collector/handlers.go b/collector/cmd/loza-collector/handlers.go index d0d424d..4bf2647 100644 --- a/collector/cmd/loza-collector/handlers.go +++ b/collector/cmd/loza-collector/handlers.go @@ -16,6 +16,7 @@ import ( processing "github.com/astraive/loza/collector/internal/processing" serverruntime "github.com/astraive/loza/collector/internal/server" "github.com/astraive/loza/collector/internal/validation" + publichttp "github.com/astraive/loza/collector/server/http" speccontract "github.com/astraive/loza/spec/generated/go/contract" ) @@ -36,6 +37,54 @@ func ingestRejectResponse(requestID, code, message string, retryable bool) inges } } +const ( + collectorOwnershipColumn = "collector" + environmentOwnershipColumn = "environment" +) + +func isCanonicalCollectorRoute(r *http.Request) bool { + return r.PathValue("collector") != "" +} + +func canonicalCollectorScope(r *http.Request) (publichttp.AuthorizedCollector, bool) { + if !isCanonicalCollectorRoute(r) { + return publichttp.AuthorizedCollector{}, false + } + scope, ok := publichttp.AuthorizedCollectorFromContext(r.Context()) + if !ok || strings.TrimSpace(scope.Name) == "" { + return publichttp.AuthorizedCollector{}, false + } + return scope, true +} + +func rejectUnsupportedScopedOperation(w http.ResponseWriter, r *http.Request) bool { + if !isCanonicalCollectorRoute(r) { + return false + } + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "scoped_operation_unsupported"}) + return true +} + +func stampAuthorizedOwnership(raw []byte, scope publichttp.AuthorizedCollector) ([]byte, *governanceError) { + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return raw, &governanceError{Code: "invalid_json", Message: err.Error()} + } + if value, exists := payload[collectorOwnershipColumn]; exists && value != scope.Name { + return raw, &governanceError{Code: "ownership_conflict", Message: "event collector conflicts with authorized collector"} + } + if value, exists := payload[environmentOwnershipColumn]; exists && value != scope.Environment { + return raw, &governanceError{Code: "ownership_conflict", Message: "event environment conflicts with authorized environment"} + } + payload[collectorOwnershipColumn] = scope.Name + payload[environmentOwnershipColumn] = scope.Environment + stamped, err := json.Marshal(payload) + if err != nil { + return raw, &governanceError{Code: "event_rewrite_failed", Message: err.Error(), Retryable: true} + } + return stamped, nil +} + func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) { s.metrics.requestsTotal.Add(1) requestID := newIngestRequestID() @@ -52,6 +101,13 @@ func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) { // Auth is handled by middleware (auth.Middleware + auth.RequirePermission). // When auth is disabled, middleware is not applied and requests pass through. + scope, scoped := canonicalCollectorScope(r) + if isCanonicalCollectorRoute(r) && !scoped { + s.metrics.eventsRejected.Add(1) + writeJSON(w, http.StatusForbidden, ingestRejectResponse(requestID, "collector_scope_missing", "authorized collector scope is required", false)) + return + } + if !isSupportedIngestContentType(r.Header.Get("Content-Type")) { s.metrics.eventsRejected.Add(1) writeJSON(w, http.StatusUnsupportedMediaType, ingestRejectResponse(requestID, @@ -139,6 +195,16 @@ func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) { raw = prepared } + if scoped { + stamped, gerr := stampAuthorizedOwnership(raw, scope) + if gerr != nil { + s.metrics.eventsInvalid.Add(1) + resp.AddInvalid(i, eventID, gerr.Code, gerr.Message) + continue + } + raw = stamped + } + if s.cfg.reliabilityMode == "spool" || s.cfg.reliabilityMode == "hybrid" { // For spool mode, deduplicate at ingest time to avoid unnecessary spooling if s.dedupeEnabled() { @@ -342,6 +408,9 @@ func (s *collectorState) handleMetrics(w http.ResponseWriter, r *http.Request) { func (s *collectorState) handleTail(w http.ResponseWriter, r *http.Request) { // Auth is handled by middleware. + if rejectUnsupportedScopedOperation(w, r) { + return + } filters, err := serverruntime.ParseTailFilters(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) diff --git a/collector/cmd/loza-collector/handlers_deletion.go b/collector/cmd/loza-collector/handlers_deletion.go index ece295c..5a01854 100644 --- a/collector/cmd/loza-collector/handlers_deletion.go +++ b/collector/cmd/loza-collector/handlers_deletion.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "time" + + publichttp "github.com/astraive/loza/collector/server/http" ) // escapeLIKE escapes LIKE metacharacters to prevent wildcard injection. @@ -29,6 +31,13 @@ type DeletionResponse struct { Timestamp time.Time `json:"timestamp"` } +func ownershipDeletePredicate(scope *publichttp.AuthorizedCollector) (string, []any) { + if scope == nil { + return "", nil + } + return ` AND collector = ? AND environment = ?`, []any{scope.Name, scope.Environment} +} + // handleDeleteEvents deletes events based on query parameters. // Supports: /events/by-tenant/{tenant_id}, /events/by-user/{user_id}, /events/{event_id}. func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Request) { @@ -51,6 +60,16 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque return } + var scope *publichttp.AuthorizedCollector + if isCanonicalCollectorRoute(r) { + authorizedScope, ok := canonicalCollectorScope(r) + if !ok { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "collector_scope_missing"}) + return + } + scope = &authorizedScope + } + // Verify that queryDB is available if s.queryDB == nil { logJSON("error", "deletion_unavailable", map[string]any{ @@ -87,7 +106,7 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque }) return } - deletedCount, err = s.deleteEventsByTenant(r.Context(), tenantID) + deletedCount, err = s.deleteEventsByTenant(r.Context(), tenantID, scope) deletionType = "by_tenant" case strings.Contains(r.URL.Path, "/by-user/"): @@ -99,14 +118,14 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque }) return } - deletedCount, err = s.deleteEventsByUser(r.Context(), userID) + deletedCount, err = s.deleteEventsByUser(r.Context(), userID, scope) deletionType = "by_user" default: // DELETE /events/{event_id} if len(pathParts) > 1 && pathParts[len(pathParts)-1] != "" { eventID := pathParts[len(pathParts)-1] - deletedCount, err = s.deleteEvent(r.Context(), eventID) + deletedCount, err = s.deleteEvent(r.Context(), eventID, scope) deletionType = "by_event_id" } else { writeJSON(w, http.StatusBadRequest, map[string]any{ @@ -147,15 +166,17 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque }) } -// deleteEventsByTenant deletes all events for a specific tenant -func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID string) (int64, error) { +// deleteEventsByTenant deletes all events for a specific tenant. +func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID string, scope *publichttp.AuthorizedCollector) (int64, error) { tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable) if err != nil { return 0, err } - query := fmt.Sprintf(`DELETE FROM %s WHERE tenant_id = ?`, tableIdent) + predicate, ownershipArgs := ownershipDeletePredicate(scope) + query := fmt.Sprintf(`DELETE FROM %s WHERE tenant_id = ?%s`, tableIdent, predicate) + args := append([]any{tenantID}, ownershipArgs...) - result, err := s.queryDB.ExecContext(ctx, query, tenantID) + result, err := s.queryDB.ExecContext(ctx, query, args...) if err != nil { return 0, err } @@ -168,8 +189,8 @@ func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID stri return rowsAffected, nil } -// deleteEventsByUser deletes all events for a specific user -func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string) (int64, error) { +// deleteEventsByUser deletes all events for a specific user. +func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string, scope *publichttp.AuthorizedCollector) (int64, error) { tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable) if err != nil { return 0, err @@ -181,9 +202,11 @@ func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string) return 0, err } } - query := fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? OR %s LIKE ? ESCAPE '\'`, tableIdent, rawIdent) + predicate, ownershipArgs := ownershipDeletePredicate(scope) + query := fmt.Sprintf(`DELETE FROM %s WHERE (user_id = ? OR %s LIKE ? ESCAPE '\')%s`, tableIdent, rawIdent, predicate) + args := append([]any{userID, fmt.Sprintf("%%\"user_id\":\"%s\"%%", escapeLIKE(userID))}, ownershipArgs...) - result, err := s.queryDB.ExecContext(ctx, query, userID, fmt.Sprintf("%%\"user_id\":\"%s\"%%", escapeLIKE(userID))) + result, err := s.queryDB.ExecContext(ctx, query, args...) if err != nil { return 0, err } @@ -196,15 +219,17 @@ func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string) return rowsAffected, nil } -// deleteEvent deletes a specific event by ID -func (s *collectorState) deleteEvent(ctx context.Context, eventID string) (int64, error) { +// deleteEvent deletes a specific event by ID. +func (s *collectorState) deleteEvent(ctx context.Context, eventID string, scope *publichttp.AuthorizedCollector) (int64, error) { tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable) if err != nil { return 0, err } - query := fmt.Sprintf(`DELETE FROM %s WHERE event_id = ?`, tableIdent) + predicate, ownershipArgs := ownershipDeletePredicate(scope) + query := fmt.Sprintf(`DELETE FROM %s WHERE event_id = ?%s`, tableIdent, predicate) + args := append([]any{eventID}, ownershipArgs...) - result, err := s.queryDB.ExecContext(ctx, query, eventID) + result, err := s.queryDB.ExecContext(ctx, query, args...) if err != nil { return 0, err } diff --git a/collector/cmd/loza-collector/main_test.go b/collector/cmd/loza-collector/main_test.go index b0c9047..08f6da4 100644 --- a/collector/cmd/loza-collector/main_test.go +++ b/collector/cmd/loza-collector/main_test.go @@ -71,6 +71,8 @@ func testCollectorConfig() collectorConfig { cfg.retryMaxAttempts = 1 cfg.retryInitialBackoff = time.Millisecond cfg.retryMaxBackoff = time.Millisecond + cfg.authDefaultCollector = "default" + cfg.authCollectors = []string{"default"} return cfg } @@ -179,11 +181,11 @@ func TestConfiguredAdminKeyCanReadStatus(t *testing.T) { cfg.authNegativeCacheTTL = time.Second cfg.authKeys = []collectorAuthKey{ { - name: "admin", - keyID: "kadmin", - secret: "adminsecret", - kind: auth.KeyKindSecret, - roles: []auth.Role{auth.RoleProjectAdmin}, + name: "admin", + keyID: "kadmin", + secret: "adminsecret", + kind: auth.KeyKindSecret, + roles: []auth.Role{auth.RoleProjectAdmin}, }, } state := &collectorState{ @@ -210,6 +212,64 @@ func TestConfiguredAdminKeyCanReadStatus(t *testing.T) { } } +func TestConfiguredScopedKeyRestrictsCanonicalRoutes(t *testing.T) { + cfg := testCollectorConfig() + cfg.authEnabled = true + cfg.authServerSecret = "test-auth-server-secret" + cfg.authCacheTTL = time.Minute + cfg.authNegativeCacheTTL = time.Second + cfg.authCollectors = []string{"orders", "billing"} + cfg.authKeys = []collectorAuthKey{{ + name: "orders-writer", + keyID: "korders", + secret: "orderssecret", + kind: auth.KeyKindSecret, + collector: "orders", + permissions: []auth.Permission{auth.PermEventsWrite}, + allowedEnvs: []string{"production"}, + }} + state := &collectorState{ + cfg: cfg, + ingestSink: &fakeSink{}, + rateLimiter: rate.NewLimiter(rate.Limit(1000), 1000), + } + state.ready.Store(true) + srv := httptest.NewServer(buildMux(state)) + defer srv.Close() + + request := func(method, path string) *http.Request { + req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(`{"event":"scoped"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer lx_sec_live_korders_orderssecret") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Loza-Env", "production") + return req + } + for _, tc := range []struct { + name string + method string + path string + want int + }{ + {name: "authorized write", method: http.MethodPost, path: "/collectors/orders/events", want: http.StatusAccepted}, + {name: "cross collector denied", method: http.MethodPost, path: "/collectors/billing/events", want: http.StatusForbidden}, + {name: "ungranted read denied", method: http.MethodGet, path: "/collectors/orders/status", want: http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + resp, err := http.DefaultClient.Do(request(tc.method, tc.path)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != tc.want { + t.Fatalf("status = %d, want %d", resp.StatusCode, tc.want) + } + }) + } +} + func TestHandleIngestPartialSuccess(t *testing.T) { sink := &fakeSink{} cfg := testCollectorConfig() diff --git a/collector/cmd/loza-collector/scoped_ownership_test.go b/collector/cmd/loza-collector/scoped_ownership_test.go new file mode 100644 index 0000000..88a9ee9 --- /dev/null +++ b/collector/cmd/loza-collector/scoped_ownership_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + publichttp "github.com/astraive/loza/collector/server/http" + _ "github.com/marcboeker/go-duckdb" +) + +func scopedRequest(method, path, collector, environment, body string) *http.Request { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req = req.WithContext(publichttp.WithAuthorizedCollector(req.Context(), collector, environment)) + req.SetPathValue("collector", collector) + return req +} + +func TestScopedIngestStampsAuthorizedOwnershipAndRejectsForgery(t *testing.T) { + cfg := testCollectorConfig() + cfg.rateLimitEnabled = false + sink := &fakeSink{} + state := &collectorState{ + cfg: cfg, + ingestSink: sink, + } + + accepted := scopedRequest(http.MethodPost, "/collectors/collector-a/events", "collector-a", "production", `{"event":"accepted"}`) + accepted.Header.Set("Content-Type", "application/json") + acceptedRecorder := httptest.NewRecorder() + state.handleIngest(acceptedRecorder, accepted) + if acceptedRecorder.Code != http.StatusAccepted { + t.Fatalf("expected scoped event to be accepted, got %d: %s", acceptedRecorder.Code, acceptedRecorder.Body.String()) + } + if len(sink.events) != 1 { + t.Fatalf("expected one stored event, got %d", len(sink.events)) + } + var stored map[string]any + if err := json.Unmarshal(sink.events[0], &stored); err != nil { + t.Fatalf("decode stored event: %v", err) + } + if stored[collectorOwnershipColumn] != "collector-a" || stored[environmentOwnershipColumn] != "production" { + t.Fatalf("stored event ownership = %#v, want collector-a/production", stored) + } + + forged := scopedRequest(http.MethodPost, "/collectors/collector-a/events", "collector-a", "production", `{"event":"forged","collector":"collector-b","environment":"production"}`) + forged.Header.Set("Content-Type", "application/json") + forgedRecorder := httptest.NewRecorder() + state.handleIngest(forgedRecorder, forged) + if forgedRecorder.Code != http.StatusBadRequest { + t.Fatalf("expected forged ownership to be rejected, got %d: %s", forgedRecorder.Code, forgedRecorder.Body.String()) + } + if len(sink.events) != 1 { + t.Fatalf("forged event was stored; got %d stored events", len(sink.events)) + } +} + +func TestScopedDeletionDoesNotRemoveAnotherCollectorsEvent(t *testing.T) { + db, err := sql.Open("duckdb", ":memory:") + if err != nil { + t.Fatalf("open duckdb: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE events ( + event_id VARCHAR, + tenant_id VARCHAR, + user_id VARCHAR, + collector VARCHAR, + environment VARCHAR, + raw VARCHAR + )`); err != nil { + t.Fatalf("create events table: %v", err) + } + for _, collector := range []string{"collector-a", "collector-b"} { + if _, err := db.Exec(`INSERT INTO events (event_id, collector, environment) VALUES (?, ?, ?)`, "shared-event", collector, "production"); err != nil { + t.Fatalf("insert %s event: %v", collector, err) + } + } + state := &collectorState{ + cfg: collectorConfig{ + duckDBTable: "events", + }, + queryDB: db, + } + + req := scopedRequest(http.MethodDelete, "/collectors/collector-a/events/shared-event", "collector-a", "production", "") + rec := httptest.NewRecorder() + state.handleDeleteEvents(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected scoped deletion to succeed, got %d: %s", rec.Code, rec.Body.String()) + } + var remainingA, remainingB int + if err := db.QueryRow(`SELECT COUNT(*) FROM events WHERE collector = 'collector-a'`).Scan(&remainingA); err != nil { + t.Fatalf("count collector-a rows: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM events WHERE collector = 'collector-b'`).Scan(&remainingB); err != nil { + t.Fatalf("count collector-b rows: %v", err) + } + if remainingA != 0 || remainingB != 1 { + t.Fatalf("remaining rows collector-a=%d collector-b=%d, want 0 and 1", remainingA, remainingB) + } +} + +func TestScopedRawSQLQueryIsRejected(t *testing.T) { + state := &collectorState{cfg: testCollectorConfig()} + req := scopedRequest(http.MethodPost, "/collectors/collector-a/query", "collector-a", "production", `{"sql":"SELECT * FROM events"}`) + rec := httptest.NewRecorder() + + state.handleQuery(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected scoped raw SQL to be rejected, got %d: %s", rec.Code, rec.Body.String()) + } + + var response map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response["error"] != "scoped_raw_sql_unsupported" { + t.Fatalf("error = %#v, want scoped_raw_sql_unsupported", response["error"]) + } +} + +func TestScopedQueryReadsOnlyAuthorizedOwnership(t *testing.T) { + db, err := sql.Open("duckdb", ":memory:") + if err != nil { + t.Fatalf("open duckdb: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE events (event_id VARCHAR, collector VARCHAR, environment VARCHAR)`); err != nil { + t.Fatalf("create events table: %v", err) + } + for _, row := range [][3]string{{"a-1", "collector-a", "production"}, {"b-1", "collector-b", "production"}} { + if _, err := db.Exec(`INSERT INTO events VALUES (?, ?, ?)`, row[0], row[1], row[2]); err != nil { + t.Fatalf("insert event: %v", err) + } + } + state := &collectorState{cfg: collectorConfig{duckDBTable: "events"}, queryDB: db} + rec := httptest.NewRecorder() + state.handleQuery(rec, scopedRequest(http.MethodPost, "/collectors/collector-a/query", "collector-a", "production", `{"limit":5}`)) + if rec.Code != http.StatusOK { + t.Fatalf("expected scoped read to succeed, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "b-1") || !strings.Contains(rec.Body.String(), "a-1") { + t.Fatalf("scoped query leaked or omitted rows: %s", rec.Body.String()) + } +} + +func TestEnsureSchemaMigratesOwnershipProjectionColumns(t *testing.T) { + db, err := sql.Open("duckdb", ":memory:") + if err != nil { + t.Fatalf("open duckdb: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE events (raw VARCHAR)`); err != nil { + t.Fatalf("create legacy events table: %v", err) + } + + if err := ensureSchema(db, testCollectorConfig()); err != nil { + t.Fatalf("migrate schema: %v", err) + } + + for _, column := range []string{collectorOwnershipColumn, environmentOwnershipColumn} { + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'events' AND column_name = ?`, column).Scan(&count); err != nil { + t.Fatalf("check %s column: %v", column, err) + } + if count != 1 { + t.Fatalf("ownership column %q missing after migration", column) + } + } +} + +func TestScopedTailAndReplayAreRejected(t *testing.T) { + state := &collectorState{cfg: testCollectorConfig()} + for _, tc := range []struct { + name string + method string + path string + body string + handle func(http.ResponseWriter, *http.Request) + }{ + {name: "tail", method: http.MethodGet, path: "/collectors/collector-a/tail", handle: state.handleTail}, + {name: "replay", method: http.MethodPost, path: "/collectors/collector-a/replay", body: `{"events":[]}`, handle: state.handleReplay}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tc.handle(rec, scopedRequest(tc.method, tc.path, "collector-a", "production", tc.body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected scoped operation to be rejected, got %d: %s", rec.Code, rec.Body.String()) + } + var response map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response["error"] != "scoped_operation_unsupported" { + t.Fatalf("error = %#v, want scoped_operation_unsupported", response["error"]) + } + }) + } +} diff --git a/collector/cmd/loza-collector/server.go b/collector/cmd/loza-collector/server.go index 7a9678c..6eaeac9 100644 --- a/collector/cmd/loza-collector/server.go +++ b/collector/cmd/loza-collector/server.go @@ -31,6 +31,8 @@ import ( ) func runCollector(cfg collectorConfig) error { + cfg = withOwnershipProjections(cfg) + var ( db *sql.DB err error @@ -459,6 +461,30 @@ func buildMux(state *collectorState) *http.ServeMux { tailWSHandler := serverruntime.NewTailWebSocketHandler(state.cfg.serverConfig.HTTP, state) var protector publichttp.RouteProtector + var authMW func(http.Handler) http.Handler + collectorProtector := func(next http.Handler, permission string, resolve publichttp.CollectorResolver, mode publichttp.CollectorRouteMode) http.Handler { + guard := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + collector, ok := resolve(r) + if !ok { + http.NotFound(w, r) + return + } + environment := strings.TrimSpace(r.Header.Get("X-Loza-Env")) + if state.cfg.authEnabled && mode == publichttp.CanonicalCollectorRoute { + ac := auth.GetAuthContext(r.Context()) + if ac == nil || !ac.AuthorizesCollector(collector, environment, auth.Permission(permission)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + ctx := publichttp.WithAuthorizedCollector(r.Context(), collector, environment) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + if state.cfg.authEnabled && mode == publichttp.CanonicalCollectorRoute { + return authMW(guard) + } + return guard + } if state.cfg.authEnabled { if state.keyStore == nil || state.keyCache == nil || len(state.serverSecret) == 0 { if err := initializeAuthState(state); err != nil { @@ -474,7 +500,7 @@ func buildMux(state *collectorState) *http.ServeMux { trustedCIDRs = append(trustedCIDRs, ipNet) } - authMW := auth.Middleware(state.keyStore, state.keyCache, state.serverSecret, + authMW = auth.Middleware(state.keyStore, state.keyCache, state.serverSecret, auth.WithAllowLocalDevKeys(state.cfg.authAllowLocalDevKeys), auth.WithTrustedProxies(trustedCIDRs), auth.WithRateLimiter(state.keyRateLimiter), @@ -484,7 +510,19 @@ func buildMux(state *collectorState) *http.ServeMux { } } - return publichttp.BuildMux(state.cfg.ingestPath, state.cfg.healthPath, state.cfg.readyPath, state.cfg.metricsPath, state.cfg.metricsPrometheus, state.metricsHandler(), tailWSHandler, state, protector) + return publichttp.BuildMux( + state.cfg.ingestPath, + state.cfg.healthPath, + state.cfg.readyPath, + state.cfg.metricsPath, + state.cfg.metricsPrometheus, + state.metricsHandler(), + tailWSHandler, + state, + protector, + collectorProtector, + state.cfg.authDefaultCollector, + ) } func initializeAuthState(state *collectorState) error { @@ -494,8 +532,8 @@ func initializeAuthState(state *collectorState) error { if strings.TrimSpace(state.cfg.authServerSecret) == "" { return errors.New("auth.enabled requires a resolved auth.server_secret") } - if len(state.cfg.authKeys) == 0 && strings.TrimSpace(state.cfg.apiKey) == "" { - return errors.New("auth.enabled requires at least one configured key or auth.value") + if len(state.cfg.authKeys) == 0 && len(state.cfg.authTokens) == 0 && strings.TrimSpace(state.cfg.apiKey) == "" { + return errors.New("auth.enabled requires at least one configured key, token, or auth.value") } if state.cfg.authCacheTTL <= 0 || state.cfg.authNegativeCacheTTL <= 0 { return errors.New("auth cache TTLs must be positive") @@ -550,14 +588,28 @@ func (s *memoryKeyStore) RevokeKey(keyID string) error { return nil } func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memoryKeyStore { - store := &memoryKeyStore{keys: make(map[string]*auth.KeyRecord, len(cfg.authKeys)+1)} + store := &memoryKeyStore{keys: make(map[string]*auth.KeyRecord, len(cfg.authKeys)+len(cfg.authGrants)+len(cfg.authTokens)+1)} for _, key := range cfg.authKeys { + var collectorGrants []auth.CollectorGrant + if key.collector != "" { + permissions := make(map[auth.Permission]bool, len(key.permissions)) + for _, permission := range key.permissions { + permissions[permission] = true + } + collectorGrants = []auth.CollectorGrant{{ + Collector: key.collector, + Environments: append([]string(nil), key.allowedEnvs...), + Permissions: permissions, + }} + } store.keys[key.keyID] = &auth.KeyRecord{ ID: key.name, KeyID: key.keyID, SecretHash: auth.HashSecret(key.secret, serverSecret), Kind: key.kind, + Mode: key.mode, Roles: append([]auth.Role(nil), key.roles...), + CollectorGrants: collectorGrants, AllowedEnvs: append([]string(nil), key.allowedEnvs...), AllowedServices: append([]string(nil), key.allowedServices...), AllowedOrigins: append([]string(nil), key.allowedOrigins...), @@ -570,6 +622,67 @@ func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memo store.keys[key.keyID].ID = key.keyID } } + for _, grant := range cfg.authGrants { + permissions := make(map[auth.Permission]bool, len(grant.permissions)) + for _, permission := range grant.permissions { + permissions[permission] = true + } + store.keys[grant.keyID] = &auth.KeyRecord{ + ID: grant.name, + KeyID: grant.keyID, + SecretHash: auth.HashSecret(grant.secret, serverSecret), + Kind: grant.kind, + CollectorGrants: []auth.CollectorGrant{{ + Collector: grant.collector, + Environments: append([]string(nil), grant.allowedEnvs...), + Permissions: permissions, + }}, + AllowedEnvs: append([]string(nil), grant.allowedEnvs...), + AllowedServices: append([]string(nil), grant.allowedServices...), + AllowedOrigins: append([]string(nil), grant.allowedOrigins...), + AllowedIPs: append([]string(nil), grant.allowedIPs...), + MaxPayloadBytes: grant.maxPayloadBytes, + MaxRequestsPerMinute: grant.maxRequestsPerMinute, + MaxEventsPerMinute: grant.maxEventsPerMinute, + } + if store.keys[grant.keyID].ID == "" { + store.keys[grant.keyID].ID = grant.keyID + } + } + for _, token := range cfg.authTokens { + tokenID := auth.TokenLookupID(token.token, serverSecret) + permissions := make(map[auth.Permission]bool, len(token.permissions)) + for _, permission := range token.permissions { + permissions[permission] = true + } + var collectorGrants []auth.CollectorGrant + if token.collector != "" { + collectorGrants = []auth.CollectorGrant{{ + Collector: token.collector, + Environments: append([]string(nil), token.allowedEnvs...), + Permissions: permissions, + }} + } + store.keys[tokenID] = &auth.KeyRecord{ + ID: token.name, + KeyID: tokenID, + SecretHash: auth.HashSecret(token.token, serverSecret), + Kind: auth.KeyKindToken, + Mode: token.mode, + Roles: append([]auth.Role(nil), token.roles...), + CollectorGrants: collectorGrants, + AllowedEnvs: append([]string(nil), token.allowedEnvs...), + AllowedServices: append([]string(nil), token.allowedServices...), + AllowedOrigins: append([]string(nil), token.allowedOrigins...), + AllowedIPs: append([]string(nil), token.allowedIPs...), + MaxPayloadBytes: token.maxPayloadBytes, + MaxRequestsPerMinute: token.maxRequestsPerMinute, + MaxEventsPerMinute: token.maxEventsPerMinute, + } + if store.keys[tokenID].ID == "" { + store.keys[tokenID].ID = tokenID + } + } if strings.TrimSpace(cfg.apiKey) == "" { return store @@ -594,7 +707,28 @@ func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memo return store } +func withOwnershipProjections(cfg collectorConfig) collectorConfig { + schema := make(map[string]string, len(cfg.duckDBSchema)+2) + for column, path := range cfg.duckDBSchema { + schema[column] = path + } + schema[collectorOwnershipColumn] = collectorOwnershipColumn + schema[environmentOwnershipColumn] = environmentOwnershipColumn + cfg.duckDBSchema = schema + + columnTypes := make(map[string]string, len(cfg.duckDBColumnTypes)+2) + for path, typ := range cfg.duckDBColumnTypes { + columnTypes[path] = typ + } + columnTypes[collectorOwnershipColumn] = "TEXT" + columnTypes[environmentOwnershipColumn] = "TEXT" + cfg.duckDBColumnTypes = columnTypes + + return cfg +} + func ensureSchema(db *sql.DB, cfg collectorConfig) error { + cfg = withOwnershipProjections(cfg) columns := make([]string, 0, len(cfg.duckDBSchema)+1) for col, path := range cfg.duckDBSchema { colIdent, err := quoteSQLIdent(col) diff --git a/collector/deploy/goreleaser.yml b/collector/deploy/goreleaser.yml index 5b2b6f4..d3a4e01 100644 --- a/collector/deploy/goreleaser.yml +++ b/collector/deploy/goreleaser.yml @@ -1,5 +1,5 @@ project_name: loza -version: 0.2.6 +version: 0.3.0 archives: - format: tar.gz builds: diff --git a/collector/deploy/helm/loza/Chart.yaml b/collector/deploy/helm/loza/Chart.yaml index 2906fda..efb33f1 100644 --- a/collector/deploy/helm/loza/Chart.yaml +++ b/collector/deploy/helm/loza/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: loza description: Loza wide-event logging system type: application -version: 0.2.6 -appVersion: "0.2.6" +version: 0.3.0 +appVersion: "0.3.0" keywords: - logging - events diff --git a/collector/deploy/helm/loza/values.yaml b/collector/deploy/helm/loza/values.yaml index ac1dd15..cf5bdfc 100644 --- a/collector/deploy/helm/loza/values.yaml +++ b/collector/deploy/helm/loza/values.yaml @@ -15,7 +15,7 @@ collector: image: repository: loza - tag: "0.2.6" + tag: "0.3.0" digest: "" pullPolicy: IfNotPresent @@ -138,7 +138,7 @@ worker: image: repository: loza-worker - tag: "0.2.6" + tag: "0.3.0" digest: "" pullPolicy: IfNotPresent diff --git a/collector/deploy/k8s/collector-deployment.yaml b/collector/deploy/k8s/collector-deployment.yaml index 54de015..b9f6dc6 100644 --- a/collector/deploy/k8s/collector-deployment.yaml +++ b/collector/deploy/k8s/collector-deployment.yaml @@ -23,7 +23,7 @@ spec: fsGroup: 1000 containers: - name: collector - image: ghcr.io/astraive/loza:0.2.6 + image: ghcr.io/astraive/loza:0.3.0 securityContext: capabilities: drop: diff --git a/collector/internal/auth/context.go b/collector/internal/auth/context.go index 908ee4e..ea6fbb3 100644 --- a/collector/internal/auth/context.go +++ b/collector/internal/auth/context.go @@ -4,6 +4,42 @@ import "context" type authContextKey struct{} +// CollectorGrant binds a principal to one named collector, its permitted +// environments, and the collector-scoped actions it may perform. +type CollectorGrant struct { + Collector string + Environments []string + Permissions map[Permission]bool +} + +// permits reports whether this grant authorizes a specific collector request. +// Empty collector or environment values are denied so callers cannot bypass +// scope resolution by omitting one of the resource dimensions. +func (g CollectorGrant) permits(collector, environment string, permission Permission) bool { + if collector == "" || environment == "" || g.Collector != collector || !g.Permissions[permission] { + return false + } + return contains(g.Environments, environment) +} + +func cloneCollectorGrants(grants []CollectorGrant) []CollectorGrant { + if len(grants) == 0 { + return nil + } + cloned := make([]CollectorGrant, len(grants)) + for i, grant := range grants { + cloned[i] = CollectorGrant{ + Collector: grant.Collector, + Environments: append([]string(nil), grant.Environments...), + Permissions: make(map[Permission]bool, len(grant.Permissions)), + } + for permission, allowed := range grant.Permissions { + cloned[i].Permissions[permission] = allowed + } + } + return cloned +} + // AuthContext holds the authenticated identity and authorization details // for a request. Attached to the request context by the auth middleware. type AuthContext struct { @@ -12,19 +48,20 @@ type AuthContext struct { APIKeyID string KeyKind KeyKind - Roles []Role - Permissions map[Permission]bool + Roles []Role + Permissions map[Permission]bool + CollectorGrants []CollectorGrant - AllowedEnvs []string - AllowedServices []string - AllowedOrigins []string - AllowedIPs []string - MaxPayloadBytes int + AllowedEnvs []string + AllowedServices []string + AllowedOrigins []string + AllowedIPs []string + MaxPayloadBytes int MaxRequestsPerMinute int - MaxEventsPerMinute int - SamplingRate float64 - AllowPII bool - AllowAttachments bool + MaxEventsPerMinute int + SamplingRate float64 + AllowPII bool + AllowAttachments bool } // WithAuthContext attaches an AuthContext to the context. @@ -59,3 +96,23 @@ func (ac *AuthContext) HasAnyPermission(perms ...Permission) bool { } return false } + +// AuthorizesCollector verifies the complete server-resolved resource scope. +// A principal without a matching collector grant is denied even when it holds +// the underlying global permission. Role-bound credentials must satisfy both +// their role and their resource scope; roleless legacy grants retain their +// explicitly configured compatibility policy. +func (ac *AuthContext) AuthorizesCollector(collector, environment string, permission Permission) bool { + if ac == nil { + return false + } + if len(ac.Roles) > 0 && !ac.HasPermission(permission) { + return false + } + for _, grant := range ac.CollectorGrants { + if grant.permits(collector, environment, permission) { + return true + } + } + return false +} diff --git a/collector/internal/auth/context_test.go b/collector/internal/auth/context_test.go new file mode 100644 index 0000000..a7ee982 --- /dev/null +++ b/collector/internal/auth/context_test.go @@ -0,0 +1,51 @@ +package auth + +import "testing" + +func TestAuthContextAuthorizesCollectorOnlyWithinGrantScope(t *testing.T) { + if (&AuthContext{}).AuthorizesCollector("payments", "prod", PermEventsWrite) { + t.Fatal("principal without collector binding must be denied") + } + + context := &AuthContext{ + CollectorGrants: []CollectorGrant{{ + Collector: "payments", + Environments: []string{"prod"}, + Permissions: map[Permission]bool{ + PermEventsWrite: true, + }, + }}, + } + + if !context.AuthorizesCollector("payments", "prod", PermEventsWrite) { + t.Fatal("expected matching collector grant to authorize request") + } + if context.AuthorizesCollector("analytics", "prod", PermEventsWrite) { + t.Fatal("cross-collector request must be denied") + } + if context.AuthorizesCollector("payments", "dev", PermEventsWrite) { + t.Fatal("ungranted environment must be denied") + } + if context.AuthorizesCollector("payments", "", PermEventsWrite) { + t.Fatal("missing environment must be denied") + } + if context.AuthorizesCollector("payments", "prod", PermEventsRead) { + t.Fatal("ungranted permission must be denied") + } +} + +func TestAuthContextScopesCannotExceedCredentialRole(t *testing.T) { + context := &AuthContext{ + Roles: []Role{RoleClient}, + Permissions: ExpandRoles([]Role{RoleClient}), + CollectorGrants: []CollectorGrant{{ + Collector: "logs", + Environments: []string{"prod"}, + Permissions: map[Permission]bool{PermLogsDelete: true}, + }}, + } + + if context.AuthorizesCollector("logs", "prod", PermLogsDelete) { + t.Fatal("client role must not escalate through a configured log scope") + } +} diff --git a/collector/internal/auth/keys.go b/collector/internal/auth/keys.go index 6f966c2..5bd8da7 100644 --- a/collector/internal/auth/keys.go +++ b/collector/internal/auth/keys.go @@ -15,8 +15,39 @@ const ( KeyKindPublic KeyKind = "pub" // lx_pub_live_kxxx_yyyy KeyKindSecret KeyKind = "sec" // lx_sec_live_kxxx_yyyy KeyKindLocal KeyKind = "local" // lx_local_dev_yyyy (dev only) + KeyKindToken KeyKind = "token" // opaque Bearer token, stored by HMAC-derived ID ) +const ( + publicAccessIDPrefix = "lx_pub_" + minPublicAccessIDToken = 32 + maxPublicAccessIDToken = 128 +) + +// IsPublicAccessID reports whether id is a valid opaque public Basic +// credential. The random component is intentionally constrained to URL-safe +// characters and a minimum entropy-bearing length; callers must never include +// an invalid value in an error because the entire ID is a bearer capability. +func IsPublicAccessID(id string) bool { + if !strings.HasPrefix(id, publicAccessIDPrefix) { + return false + } + token := id[len(publicAccessIDPrefix):] + if len(token) < minPublicAccessIDToken || len(token) > maxPublicAccessIDToken { + return false + } + for i := range len(token) { + c := token[i] + if !((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_') { + return false + } + } + return true +} + // ParsedKey holds the components of a parsed LOZA API key. type ParsedKey struct { Raw string @@ -108,6 +139,13 @@ func HashSecret(secret string, serverSecret []byte) []byte { return mac.Sum(nil) } +// TokenLookupID derives the opaque store identifier for a bearer token. The +// token itself is never used as a map key or logged; the server secret makes +// the identifier unguessable and prevents offline enumeration. +func TokenLookupID(token string, serverSecret []byte) string { + return "tok_" + fmt.Sprintf("%x", HashSecret(token, serverSecret)) +} + // CompareSecret performs constant-time comparison of two secret hashes. func CompareSecret(incoming, stored []byte) bool { return subtle.ConstantTimeCompare(incoming, stored) == 1 diff --git a/collector/internal/auth/middleware.go b/collector/internal/auth/middleware.go index 6147e4f..a32ab34 100644 --- a/collector/internal/auth/middleware.go +++ b/collector/internal/auth/middleware.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "time" + "unicode" ) // parsedCIDRNet holds a pre-parsed CIDR to avoid repeated net.ParseCIDR calls. @@ -20,27 +21,30 @@ type parsedCIDRNet struct { // cidrCache caches parsed CIDRs so they are only parsed once per unique string. var cidrCache sync.Map // map[string]*parsedCIDRNet -// KeyRecord holds the stored metadata for an API key. +// KeyRecord holds the stored metadata for an API key or opaque bearer token. type KeyRecord struct { - ID string - OrgID string - ProjectID string - KeyID string - SecretHash []byte - Kind KeyKind - Roles []Role - AllowedEnvs []string - AllowedServices []string - AllowedOrigins []string - AllowedIPs []string - MaxPayloadBytes int + ID string + OrgID string + ProjectID string + KeyID string + SecretHash []byte + Kind KeyKind + Mode AccessMode + Roles []Role + CollectorGrants []CollectorGrant + + AllowedEnvs []string + AllowedServices []string + AllowedOrigins []string + AllowedIPs []string + MaxPayloadBytes int MaxRequestsPerMinute int - MaxEventsPerMinute int - SamplingRate float64 - AllowPII bool - AllowAttachments bool - RevokedAt *time.Time - ExpiresAt *time.Time + MaxEventsPerMinute int + SamplingRate float64 + AllowPII bool + AllowAttachments bool + RevokedAt *time.Time + ExpiresAt *time.Time } // KeyStore is the interface for key storage backends. @@ -79,8 +83,8 @@ func WithRateLimiter(rl *KeyRateLimiter) MiddlewareOption { // — that's done per-route via RequirePermission. // // Validation flow: -// 1. Parse Authorization header -// 2. ParseKey → prefix, key_id, secret +// 1. Parse Authorization header (Basic, Bearer, or X-API-Key) +// 2. Map Basic username to key_id, or ParseKey for API-key credentials // 3. Cache lookup by key_id // 4. If cache miss: KeyStore.FindByKeyID() // 5. Check revoked / expired @@ -106,9 +110,9 @@ func Middleware(store KeyStore, cache *MemoryKeyCache, serverSecret []byte, opts return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ac, errCode, errReason := authenticate(r, store, cache, serverSecret, rateLimiter, mc.allowLocalDevKeys, mc.trustedProxies) + ac, errCode, _ := authenticate(r, store, cache, serverSecret, rateLimiter, mc.allowLocalDevKeys, mc.trustedProxies) if ac == nil { - slog.Warn("auth_failure", "code", errCode, "reason", errReason, "path", r.URL.Path, "remote", r.RemoteAddr) + slog.Warn("auth_failure", "code", errCode, "path", r.URL.Path, "remote", r.RemoteAddr) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Auth-Failure-Code", "unauthorized") w.Header().Set("X-Auth-Failure-Reason", "authentication required") @@ -150,35 +154,58 @@ func MakeRouteProtector() func(next http.Handler, perm string) http.Handler { } func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, serverSecret []byte, rateLimiter *KeyRateLimiter, allowLocalDevKeys bool, trustedProxies []*net.IPNet) (*AuthContext, string, string) { - // 1. Parse Authorization header - raw := extractBearerToken(r) - if raw == "" { - return nil, "missing_token", "missing Authorization header" - } - - // 2. Parse key - parsed, err := ParseKey(raw) - if err != nil { - return nil, "invalid_key_format", err.Error() - } + // 1. Parse Authorization header. Basic credentials use the username as + // the configured key ID and the password as the HMAC input. A public + // access ID is an intentionally passwordless bearer credential. Keep the + // existing Bearer/X-API-Key extraction path unchanged. + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + var keyID, secret string + var keyKind KeyKind + isBasic := strings.HasPrefix(strings.ToLower(authHeader), "basic ") + if isBasic { + username, password, ok := r.BasicAuth() + if !ok || !validBasicUsername(username) { + return nil, "invalid_basic_auth", "invalid basic credentials" + } + keyID = username + secret = password + } else { + raw := extractBearerToken(r) + if raw == "" { + return nil, "missing_token", "missing Authorization header" + } - // Handle local dev keys - if parsed.Kind == KeyKindLocal { - if !allowLocalDevKeys { - slog.Warn("local dev key rejected (allow_local_dev_keys=false)", "key", parsed.Raw[:min(len(parsed.Raw), 20)]+"...") - return nil, "local_dev_disabled", "local dev keys are not allowed in production mode" + // 2. Parse structured API keys. An unstructured Bearer credential is + // an opaque token: derive its server-secret-bound lookup ID instead + // of persisting or logging the raw token. + parsed, err := ParseKey(raw) + if err != nil { + keyID = TokenLookupID(raw, serverSecret) + secret = raw + keyKind = KeyKindToken + } else { + // Handle local dev keys + if parsed.Kind == KeyKindLocal { + if !allowLocalDevKeys { + slog.Warn("local dev key rejected (allow_local_dev_keys=false)") + return nil, "local_dev_disabled", "local dev keys are not allowed in production mode" + } + return &AuthContext{ + KeyKind: KeyKindLocal, + Permissions: ExpandRoles([]Role{RoleIngestServer}), + Roles: []Role{RoleIngestServer}, + }, "", "" + } + keyID = parsed.KeyID + secret = parsed.Secret + keyKind = parsed.Kind } - return &AuthContext{ - KeyKind: KeyKindLocal, - Permissions: ExpandRoles([]Role{RoleIngestServer}), - Roles: []Role{RoleIngestServer}, - }, "", "" } // 3. Cache lookup var record *KeyRecord if cache != nil { - if cached, ok := cache.Get(parsed.KeyID); ok { + if cached, ok := cache.Get(keyID); ok { record = cached } } @@ -186,15 +213,30 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server // 4. Store lookup on cache miss if record == nil { var err error - record, err = store.FindByKeyID(r.Context(), parsed.KeyID) + record, err = store.FindByKeyID(r.Context(), keyID) if err != nil || record == nil { if cache != nil { - cache.SetNegative(parsed.KeyID) + cache.SetNegative(keyID) } return nil, "key_not_found", "invalid api key" } if cache != nil { - cache.Set(parsed.KeyID, record, 0) // default TTL + cache.Set(keyID, record, 0) // default TTL + } + } + + // Private Basic credentials require a password. A passwordless Basic + // credential is valid only for a syntactically valid public access ID. + if isBasic { + if record.Kind == KeyKindLocal { + return nil, "key_kind_mismatch", "api key kind mismatch" + } + if record.Kind == KeyKindPublic { + if secret != "" || !IsPublicAccessID(keyID) { + return nil, "invalid_basic_auth", "invalid basic credentials" + } + } else if secret == "" { + return nil, "invalid_basic_auth", "invalid basic credentials" } } @@ -206,13 +248,13 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server return nil, "key_expired", "api key has expired" } - // 6. Verify prefix matches - if string(record.Kind) != string(parsed.Kind) { + // 6. Verify prefix matches for API-key credentials. + if !isBasic && string(record.Kind) != string(keyKind) { return nil, "key_kind_mismatch", "api key kind mismatch" } // 7. Verify secret hash - incomingHash := HashSecret(parsed.Secret, serverSecret) + incomingHash := HashSecret(secret, serverSecret) if !CompareSecret(incomingHash, record.SecretHash) { return nil, "invalid_secret", "invalid api key" } @@ -220,22 +262,23 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server // 8. Build AuthContext permissions := ExpandRoles(record.Roles) ac := &AuthContext{ - OrgID: record.OrgID, - ProjectID: record.ProjectID, - APIKeyID: record.KeyID, - KeyKind: record.Kind, - Roles: record.Roles, - Permissions: permissions, - AllowedEnvs: record.AllowedEnvs, - AllowedServices: record.AllowedServices, - AllowedOrigins: record.AllowedOrigins, - AllowedIPs: record.AllowedIPs, - MaxPayloadBytes: record.MaxPayloadBytes, + OrgID: record.OrgID, + ProjectID: record.ProjectID, + APIKeyID: record.KeyID, + KeyKind: record.Kind, + Roles: record.Roles, + Permissions: permissions, + CollectorGrants: cloneCollectorGrants(record.CollectorGrants), + AllowedEnvs: record.AllowedEnvs, + AllowedServices: record.AllowedServices, + AllowedOrigins: record.AllowedOrigins, + AllowedIPs: record.AllowedIPs, + MaxPayloadBytes: record.MaxPayloadBytes, MaxRequestsPerMinute: record.MaxRequestsPerMinute, - MaxEventsPerMinute: record.MaxEventsPerMinute, - SamplingRate: record.SamplingRate, - AllowPII: record.AllowPII, - AllowAttachments: record.AllowAttachments, + MaxEventsPerMinute: record.MaxEventsPerMinute, + SamplingRate: record.SamplingRate, + AllowPII: record.AllowPII, + AllowAttachments: record.AllowAttachments, } // Public key strict defaults @@ -292,6 +335,18 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server return ac, "", "" } +func validBasicUsername(username string) bool { + if username == "" || strings.TrimSpace(username) != username { + return false + } + for _, r := range username { + if r == ':' || unicode.IsSpace(r) { + return false + } + } + return true +} + func extractBearerToken(r *http.Request) string { auth := r.Header.Get("Authorization") if strings.HasPrefix(strings.ToLower(auth), "bearer ") { diff --git a/collector/internal/auth/middleware_test.go b/collector/internal/auth/middleware_test.go index d9547c8..e886f7c 100644 --- a/collector/internal/auth/middleware_test.go +++ b/collector/internal/auth/middleware_test.go @@ -23,29 +23,29 @@ func newTestStore(serverSecret []byte) *testKeyStore { return &testKeyStore{ keys: map[string]*KeyRecord{ "ksec1": { - ID: "id_sec1", - OrgID: "org_1", - ProjectID: "proj_1", - KeyID: "ksec1", - SecretHash: secHash, - Kind: KeyKindSecret, - Roles: []Role{RoleIngestServer}, - AllowedEnvs: []string{"prod", "staging"}, - AllowedServices: []string{"checkout-api"}, - MaxPayloadBytes: 262144, + ID: "id_sec1", + OrgID: "org_1", + ProjectID: "proj_1", + KeyID: "ksec1", + SecretHash: secHash, + Kind: KeyKindSecret, + Roles: []Role{RoleIngestServer}, + AllowedEnvs: []string{"prod", "staging"}, + AllowedServices: []string{"checkout-api"}, + MaxPayloadBytes: 262144, MaxRequestsPerMinute: 1000, }, "kpub1": { - ID: "id_pub1", - OrgID: "org_1", - ProjectID: "proj_1", - KeyID: "kpub1", - SecretHash: pubHash, - Kind: KeyKindPublic, - Roles: []Role{RoleIngestPublic}, - AllowedEnvs: []string{"prod"}, - AllowedOrigins: []string{"https://app.example.com"}, - MaxPayloadBytes: 65536, + ID: "id_pub1", + OrgID: "org_1", + ProjectID: "proj_1", + KeyID: "kpub1", + SecretHash: pubHash, + Kind: KeyKindPublic, + Roles: []Role{RoleIngestPublic}, + AllowedEnvs: []string{"prod"}, + AllowedOrigins: []string{"https://app.example.com"}, + MaxPayloadBytes: 65536, MaxRequestsPerMinute: 100, }, "krevoked": { @@ -104,6 +104,83 @@ func TestMiddleware_ValidSecretKey(t *testing.T) { } } +func TestMiddleware_ValidBasicAuth(t *testing.T) { + serverSecret := []byte("test-server-secret") + store := newTestStore(serverSecret) + cache := NewMemoryKeyCache(10*time.Second, 5*time.Second) + defer cache.Close() + + mw := Middleware(store, cache, serverSecret) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ac := GetAuthContext(r.Context()) + if ac == nil { + t.Fatal("expected auth context") + } + if ac.APIKeyID != "ksec1" { + t.Errorf("APIKeyID = %q, want %q", ac.APIKeyID, "ksec1") + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/events", nil) + req.SetBasicAuth("ksec1", "testsecret") + req.Header.Set("X-Loza-Env", "prod") + req.Header.Set("X-Loza-Service", "checkout-api") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestMiddleware_BasicAuthWrongPassword(t *testing.T) { + serverSecret := []byte("test-server-secret") + store := newTestStore(serverSecret) + cache := NewMemoryKeyCache(10*time.Second, 5*time.Second) + defer cache.Close() + + mw := Middleware(store, cache, serverSecret) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + })) + + req := httptest.NewRequest("POST", "/events", nil) + req.SetBasicAuth("ksec1", "wrong-password") + req.Header.Set("X-Loza-Env", "prod") + req.Header.Set("X-Loza-Service", "checkout-api") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestMiddleware_BasicAuthUnknownUsername(t *testing.T) { + serverSecret := []byte("test-server-secret") + store := newTestStore(serverSecret) + cache := NewMemoryKeyCache(10*time.Second, 5*time.Second) + defer cache.Close() + + mw := Middleware(store, cache, serverSecret) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + })) + + req := httptest.NewRequest("POST", "/events", nil) + req.SetBasicAuth("unknown-user", "testsecret") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + func TestMiddleware_ValidPublicKey(t *testing.T) { serverSecret := []byte("test-server-secret") store := newTestStore(serverSecret) @@ -343,3 +420,36 @@ func TestRequirePermission_NoAuthContext(t *testing.T) { t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden) } } + +func TestMiddleware_PrivateTokenCarriesRBACPermissions(t *testing.T) { + serverSecret := []byte("test-server-secret") + token := "lxt_opaque_private_token_for_admin" + tokenID := TokenLookupID(token, serverSecret) + store := &testKeyStore{keys: map[string]*KeyRecord{ + tokenID: { + ID: tokenID, + KeyID: tokenID, + SecretHash: HashSecret(token, serverSecret), + Kind: KeyKindToken, + Mode: ModePrivate, + Roles: []Role{RoleAdmin}, + }, + }} + cache := NewMemoryKeyCache(time.Minute, time.Second) + defer cache.Close() + + handler := Middleware(store, cache, serverSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !GetAuthContext(r.Context()).HasPermission(PermLogsDelete) { + t.Fatal("admin token must carry logs:delete") + } + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodDelete, "/logs/1", nil) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } +} diff --git a/collector/internal/auth/roles.go b/collector/internal/auth/roles.go index 0fcacc5..9ac18dd 100644 --- a/collector/internal/auth/roles.go +++ b/collector/internal/auth/roles.go @@ -9,6 +9,8 @@ const ( PermEventsDelete Permission = "events:delete" PermLogsWrite Permission = "logs:write" PermLogsRead Permission = "logs:read" + PermLogsEdit Permission = "logs:edit" + PermLogsDelete Permission = "logs:delete" PermTracesWrite Permission = "traces:write" PermTracesRead Permission = "traces:read" PermMetricsWrite Permission = "metrics:write" @@ -21,6 +23,15 @@ const ( PermProjectAdmin Permission = "project:admin" ) +// AccessMode describes whether a credential can be exposed to an untrusted +// client. Public credentials are intentionally restricted to RoleClient. +type AccessMode string + +const ( + ModePublic AccessMode = "public" + ModePrivate AccessMode = "private" +) + // Role represents a named bundle of permissions. type Role string @@ -31,11 +42,35 @@ const ( RoleProjectReadonly Role = "project_readonly" RoleProjectOperator Role = "project_operator" RoleProjectAdmin Role = "project_admin" + RoleUser Role = "user" + RoleClient Role = "client" + RoleAdmin Role = "admin" + RoleSuperAdmin Role = "superadmin" ) // rolePermissions maps each role to its allowed permissions. // Default-deny: unknown roles get no permissions. var rolePermissions = map[Role][]Permission{ + RoleUser: { + PermLogsRead, + }, + RoleClient: { + PermLogsRead, + PermLogsWrite, + }, + RoleAdmin: { + PermLogsRead, + PermLogsWrite, + PermLogsEdit, + PermLogsDelete, + }, + RoleSuperAdmin: { + PermLogsRead, + PermLogsWrite, + PermLogsEdit, + PermLogsDelete, + PermProjectAdmin, + }, RoleIngestPublic: { PermEventsWrite, PermHeartbeatWrite, @@ -113,3 +148,24 @@ func ExpandRoles(roles []Role) map[Permission]bool { } return result } + +// IsKnownRole reports whether role resolves to an explicit, default-deny +// permission bundle. +func IsKnownRole(role Role) bool { + _, ok := rolePermissions[role] + return ok +} + +// RoleAllowedInMode keeps bearer credentials safe for browser use: public +// credentials may grant only the least-privileged client role. Private +// credentials may use every known role. +func RoleAllowedInMode(role Role, mode AccessMode) bool { + switch mode { + case ModePublic: + return role == RoleClient + case ModePrivate: + return IsKnownRole(role) + default: + return false + } +} diff --git a/collector/internal/auth/roles_test.go b/collector/internal/auth/roles_test.go new file mode 100644 index 0000000..21da910 --- /dev/null +++ b/collector/internal/auth/roles_test.go @@ -0,0 +1,45 @@ +package auth + +import "testing" + +func TestProductRolesExposeLogCapabilities(t *testing.T) { + tests := []struct { + role Role + allowed []Permission + notAllowed []Permission + }{ + {RoleUser, []Permission{PermLogsRead}, []Permission{PermLogsWrite, PermLogsEdit, PermLogsDelete}}, + {RoleClient, []Permission{PermLogsRead, PermLogsWrite}, []Permission{PermLogsEdit, PermLogsDelete}}, + {RoleAdmin, []Permission{PermLogsRead, PermLogsWrite, PermLogsEdit, PermLogsDelete}, nil}, + {RoleSuperAdmin, []Permission{PermLogsRead, PermLogsWrite, PermLogsEdit, PermLogsDelete, PermProjectAdmin}, nil}, + } + + for _, tt := range tests { + t.Run(string(tt.role), func(t *testing.T) { + for _, permission := range tt.allowed { + if !tt.role.HasPermission(permission) { + t.Errorf("%s must permit %s", tt.role, permission) + } + } + for _, permission := range tt.notAllowed { + if tt.role.HasPermission(permission) { + t.Errorf("%s must not permit %s", tt.role, permission) + } + } + }) + } +} + +func TestPublicModeRejectsPrivilegedRoles(t *testing.T) { + if !RoleAllowedInMode(RoleClient, ModePublic) { + t.Fatal("client must be usable by public credentials") + } + for _, role := range []Role{RoleUser, RoleAdmin, RoleSuperAdmin} { + if RoleAllowedInMode(role, ModePublic) { + t.Errorf("public mode must reject %s", role) + } + } + if !RoleAllowedInMode(RoleSuperAdmin, ModePrivate) { + t.Fatal("private mode must support superadmin") + } +} diff --git a/collector/internal/config/schema.go b/collector/internal/config/schema.go index 810d92a..d6f2b30 100644 --- a/collector/internal/config/schema.go +++ b/collector/internal/config/schema.go @@ -13,30 +13,30 @@ import ( ) type Config struct { - Version string `yaml:"version"` - Collector CollectorConfig `yaml:"collector"` - Auth AuthConfig `yaml:"auth"` - RateLimit RateLimitConfig `yaml:"rate_limit"` - Routes RoutesConfig `yaml:"routes"` - Storage StorageConfig `yaml:"storage"` - DuckDB DuckDBConfig `yaml:"duckdb"` - Retention RetentionConfig `yaml:"retention"` - Kafka KafkaConfig `yaml:"kafka"` - Worker WorkerConfig `yaml:"worker"` - Logging LoggingConfig `yaml:"logging"` - Metrics MetricsConfig `yaml:"metrics"` - Reliability ReliabilityConfig `yaml:"reliability"` - Limits LimitsConfig `yaml:"limits"` - Identity IdentityConfig `yaml:"identity"` - Privacy PrivacyConfig `yaml:"privacy"` - Components ComponentRegistryConfig `yaml:"components"` - Retry RetryConfig `yaml:"retry"` - DeadLetter DeadLetterConfig `yaml:"dead_letter"` - Fanout FanoutConfig `yaml:"fanout"` - Dedupe DedupeConfig `yaml:"dedupe"` - Schema SchemaGovernanceConfig `yaml:"schema_governance"` - CortexBridge CortexBridgeConfig `yaml:"cortex_bridge"` - EventBus EventBusConfig `yaml:"eventbus"` + Version string `yaml:"version"` + Collector CollectorConfig `yaml:"collector"` + Auth AuthConfig `yaml:"auth"` + RateLimit RateLimitConfig `yaml:"rate_limit"` + Routes RoutesConfig `yaml:"routes"` + Storage StorageConfig `yaml:"storage"` + DuckDB DuckDBConfig `yaml:"duckdb"` + Retention RetentionConfig `yaml:"retention"` + Kafka KafkaConfig `yaml:"kafka"` + Worker WorkerConfig `yaml:"worker"` + Logging LoggingConfig `yaml:"logging"` + Metrics MetricsConfig `yaml:"metrics"` + Reliability ReliabilityConfig `yaml:"reliability"` + Limits LimitsConfig `yaml:"limits"` + Identity IdentityConfig `yaml:"identity"` + Privacy PrivacyConfig `yaml:"privacy"` + Components ComponentRegistryConfig `yaml:"components"` + Retry RetryConfig `yaml:"retry"` + DeadLetter DeadLetterConfig `yaml:"dead_letter"` + Fanout FanoutConfig `yaml:"fanout"` + Dedupe DedupeConfig `yaml:"dedupe"` + Schema SchemaGovernanceConfig `yaml:"schema_governance"` + CortexBridge CortexBridgeConfig `yaml:"cortex_bridge"` + EventBus EventBusConfig `yaml:"eventbus"` } type CollectorConfig struct { @@ -99,23 +99,77 @@ type GraphQLConfig struct { } type AuthConfig struct { - Enabled bool `yaml:"enabled"` - Header string `yaml:"header"` - ValueEnv string `yaml:"value_env"` - Value string `yaml:"value"` - AllowLocalDevKeys bool `yaml:"allow_local_dev_keys"` - ServerSecret string `yaml:"server_secret"` - CacheTTL time.Duration `yaml:"cache_ttl"` - NegativeCacheTTL time.Duration `yaml:"negative_cache_ttl"` - Keys []AuthKeyConfig `yaml:"keys"` + Enabled bool `yaml:"enabled"` + Header string `yaml:"header"` + ValueEnv string `yaml:"value_env"` + Value string `yaml:"value"` + AllowLocalDevKeys bool `yaml:"allow_local_dev_keys"` + ServerSecret string `yaml:"server_secret"` + CacheTTL time.Duration `yaml:"cache_ttl"` + NegativeCacheTTL time.Duration `yaml:"negative_cache_ttl"` + DefaultCollector string `yaml:"default_collector"` + Collectors []AuthCollectorConfig `yaml:"collectors"` + Grants []AuthGrantConfig `yaml:"grants"` + Keys []AuthKeyConfig `yaml:"keys"` + Tokens []AuthTokenConfig `yaml:"tokens"` +} + +// AuthCollectorConfig declares a collector that may be targeted by a grant. +// Slugs are validated by the command configuration loader. +type AuthCollectorConfig struct { + Slug string `yaml:"slug"` +} + +// AuthGrantConfig binds one credential to one named collector. A private grant +// uses username and password_env; a public grant resolves an opaque public ID +// from public_id_env with no password. Public access IDs are bearer capabilities +// and must not be logged or included in validation errors. +type AuthGrantConfig struct { + Name string `yaml:"name"` + Collector string `yaml:"collector"` + Username string `yaml:"username"` + PasswordEnv string `yaml:"password_env"` + PublicIDEnv string `yaml:"public_id_env"` + Permissions []string `yaml:"permissions"` + AllowedEnvs []string `yaml:"allowed_envs"` + AllowedServices []string `yaml:"allowed_services"` + AllowedOrigins []string `yaml:"allowed_origins"` + AllowedIPs []string `yaml:"allowed_ips"` + MaxPayloadBytes int `yaml:"max_payload_bytes"` + MaxRequestsPerMinute int `yaml:"max_requests_per_minute"` + MaxEventsPerMinute int `yaml:"max_events_per_minute"` } +// AuthKeyConfig defines an API-key credential. Collector and Permissions bind +// the key to one collector resource for canonical data-plane routes. Both must +// be supplied together; unscoped keys are retained only for legacy root routes. type AuthKeyConfig struct { Name string `yaml:"name"` KeyID string `yaml:"key_id"` SecretEnv string `yaml:"secret_env"` Kind string `yaml:"kind"` + Mode string `yaml:"mode"` + Roles []string `yaml:"roles"` + Collector string `yaml:"collector"` + Permissions []string `yaml:"permissions"` + AllowedEnvs []string `yaml:"allowed_envs"` + AllowedServices []string `yaml:"allowed_services"` + AllowedOrigins []string `yaml:"allowed_origins"` + AllowedIPs []string `yaml:"allowed_ips"` + MaxPayloadBytes int `yaml:"max_payload_bytes"` + MaxRequestsPerMinute int `yaml:"max_requests_per_minute"` + MaxEventsPerMinute int `yaml:"max_events_per_minute"` +} + +// AuthTokenConfig defines an opaque Bearer token. Token values are sourced +// only from an environment variable and are stored by an HMAC-derived ID. +type AuthTokenConfig struct { + Name string `yaml:"name"` + TokenEnv string `yaml:"token_env"` + Mode string `yaml:"mode"` Roles []string `yaml:"roles"` + Collector string `yaml:"collector"` + Permissions []string `yaml:"permissions"` AllowedEnvs []string `yaml:"allowed_envs"` AllowedServices []string `yaml:"allowed_services"` AllowedOrigins []string `yaml:"allowed_origins"` @@ -145,30 +199,30 @@ type StorageConfig struct { } type DuckDBConfig struct { - Path string `yaml:"path"` - Driver string `yaml:"driver"` - Table string `yaml:"table"` - RawColumn string `yaml:"raw_column"` - StoreRaw bool `yaml:"store_raw"` - CheckpointOnShutdown bool `yaml:"checkpoint_on_shutdown"` - CheckpointInterval time.Duration `yaml:"checkpoint_interval"` - MaxOpenConns int `yaml:"max_open_conns"` - MaxIdleConns int `yaml:"max_idle_conns"` - BatchSize int `yaml:"batch_size"` - FlushInterval time.Duration `yaml:"flush_interval"` - WriterLoop bool `yaml:"writer_loop"` - WriterQueueSize int `yaml:"writer_queue_size"` + Path string `yaml:"path"` + Driver string `yaml:"driver"` + Table string `yaml:"table"` + RawColumn string `yaml:"raw_column"` + StoreRaw bool `yaml:"store_raw"` + CheckpointOnShutdown bool `yaml:"checkpoint_on_shutdown"` + CheckpointInterval time.Duration `yaml:"checkpoint_interval"` + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + BatchSize int `yaml:"batch_size"` + FlushInterval time.Duration `yaml:"flush_interval"` + WriterLoop bool `yaml:"writer_loop"` + WriterQueueSize int `yaml:"writer_queue_size"` // UseAppender enables DuckDB Appender API for higher throughput when available. - UseAppender bool `yaml:"use_appender"` + UseAppender bool `yaml:"use_appender"` // WriteTimeout bounds background writes when no caller deadline is present. - WriteTimeout time.Duration `yaml:"write_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` // RetryAttempts controls retries for transient DB errors. - RetryAttempts int `yaml:"retry_attempts"` + RetryAttempts int `yaml:"retry_attempts"` // RetryBackoff is the backoff between retry attempts. - RetryBackoff time.Duration `yaml:"retry_backoff"` - Export DuckDBExport `yaml:"export"` - Schema map[string]string `yaml:"schema"` - ColumnTypes map[string]string `yaml:"column_types"` + RetryBackoff time.Duration `yaml:"retry_backoff"` + Export DuckDBExport `yaml:"export"` + Schema map[string]string `yaml:"schema"` + ColumnTypes map[string]string `yaml:"column_types"` } type DuckDBExport struct { @@ -424,14 +478,14 @@ type CortexBridgeConfig struct { } type EventBusConfig struct { - Type string `yaml:"type"` - Topic string `yaml:"topic"` - DLQTopic string `yaml:"dlq_topic"` - ConsumerGroup string `yaml:"consumer_group"` - Memory EBMemoryConfig `yaml:"memory"` - Redis EBRedisConfig `yaml:"redis"` - NATS EBNATSConfig `yaml:"nats"` - Kafka EBKafkaConfig `yaml:"kafka"` + Type string `yaml:"type"` + Topic string `yaml:"topic"` + DLQTopic string `yaml:"dlq_topic"` + ConsumerGroup string `yaml:"consumer_group"` + Memory EBMemoryConfig `yaml:"memory"` + Redis EBRedisConfig `yaml:"redis"` + NATS EBNATSConfig `yaml:"nats"` + Kafka EBKafkaConfig `yaml:"kafka"` } type EBMemoryConfig struct { diff --git a/collector/internal/version/version.go b/collector/internal/version/version.go index 178f038..3babf31 100644 --- a/collector/internal/version/version.go +++ b/collector/internal/version/version.go @@ -6,7 +6,7 @@ import ( "sync" ) -const fallbackVersion = "0.2.6" +const fallbackVersion = "0.3.0" var ( once sync.Once diff --git a/collector/loza-collector.defaults.yaml b/collector/loza-collector.defaults.yaml index a0bbe59..c9d19e4 100644 --- a/collector/loza-collector.defaults.yaml +++ b/collector/loza-collector.defaults.yaml @@ -42,12 +42,14 @@ auth: key_id: kingest secret_env: COLLECTOR_INGEST_KEY_SECRET kind: sec + mode: private roles: - collector_ingest_server - name: collector-admin key_id: kadmin secret_env: COLLECTOR_ADMIN_KEY_SECRET kind: sec + mode: private roles: - project_admin diff --git a/collector/loza.yaml b/collector/loza.yaml index 0131f5e..2871414 100644 --- a/collector/loza.yaml +++ b/collector/loza.yaml @@ -1,6 +1,6 @@ name: loza-collector kind: docker -version: 0.2.6 +version: 0.3.0 description: High-performance event collector with DuckDB storage license: MIT repository: https://github.com/Astraive/loza diff --git a/collector/server/http/server.go b/collector/server/http/server.go index a3a8ec0..a5c419f 100644 --- a/collector/server/http/server.go +++ b/collector/server/http/server.go @@ -1,6 +1,8 @@ package httpserver import ( + "context" + "encoding/json" "net/http" "os" "strings" @@ -51,31 +53,103 @@ type PublicHandlerSet interface { // The perm string is the required permission name (e.g., "events:write"). type RouteProtector func(next http.Handler, perm string) http.Handler -// BuildMux constructs the HTTP route table. When protect is non-nil, protected -// routes are wrapped with per-route permission checks. Public routes (health, -// ready, version, status) are never wrapped. -func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnabled bool, metricsHandler http.Handler, tailWebSocketHandler http.Handler, handlers PublicHandlerSet, protect RouteProtector) *http.ServeMux { +// CollectorResolver resolves the target collector for a data-plane request. +// It returns false when the request has no authorized collector mapping. +type CollectorResolver func(*http.Request) (string, bool) + +// CollectorRouteMode identifies whether a data route uses canonical resource +// authorization or its explicitly configured legacy default mapping. +type CollectorRouteMode uint8 + +const ( + CanonicalCollectorRoute CollectorRouteMode = iota + LegacyCollectorRoute +) + +// CollectorRouteProtector authorizes a route after resolving its collector. +// Implementations must attach the resolved collector and environment only after +// authorization succeeds. Canonical routes require collector grants; legacy +// routes retain the configured default collector's compatibility policy. +type CollectorRouteProtector func(next http.Handler, perm string, resolve CollectorResolver, mode CollectorRouteMode) http.Handler + +// AuthorizedCollector is the server-authorized resource scope for a request. +// It is never populated from event or query payload fields. +type AuthorizedCollector struct { + Name string + Environment string +} + +type authorizedCollectorContextKey struct{} + +// WithAuthorizedCollector attaches an already-authorized collector scope. +func WithAuthorizedCollector(ctx context.Context, collector, environment string) context.Context { + return context.WithValue(ctx, authorizedCollectorContextKey{}, AuthorizedCollector{ + Name: collector, + Environment: environment, + }) +} + +// AuthorizedCollectorFromContext returns the collector scope authorized for the +// current request. It is absent before route authorization succeeds. +func AuthorizedCollectorFromContext(ctx context.Context) (AuthorizedCollector, bool) { + scope, ok := ctx.Value(authorizedCollectorContextKey{}).(AuthorizedCollector) + return scope, ok +} + +// BuildMux constructs the HTTP route table. Collector data routes are +// registered under /collectors/{collector}/... and passed through +// collectorProtect. Legacy routes are registered only when defaultCollector is +// explicitly configured, and are bound to that collector by the resolver. +// Public routes (health, ready, version) are never wrapped. +func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnabled bool, metricsHandler http.Handler, tailWebSocketHandler http.Handler, handlers PublicHandlerSet, protect RouteProtector, collectorProtect CollectorRouteProtector, defaultCollector string) *http.ServeMux { mux := http.NewServeMux() + defaultCollector = strings.TrimSpace(defaultCollector) + scopedOperationUnsupported := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_operation_unsupported"}) + } - // Helper: register a route, optionally protected - route := func(method, pattern string, h http.HandlerFunc, perm string) { - if protect != nil && perm != "" { - mux.Handle(method+" "+pattern, protect(h, perm)) - } else { - mux.HandleFunc(method+" "+pattern, h) + // registerDataRoute registers the canonical collector-scoped endpoint and, + // only during an explicit migration, its legacy default-collector equivalent. + registerDataRoute := func(method, pattern string, canonical, legacy http.Handler, routePerm, collectorPerm string) { + wrap := func(h http.Handler, resolve CollectorResolver, mode CollectorRouteMode) http.Handler { + next := h + if collectorProtect != nil { + next = collectorProtect(next, collectorPerm, resolve, mode) + } + // The historical permission vocabulary remains only on explicitly + // configured legacy routes. Canonical routes use collector grants. + if mode == LegacyCollectorRoute && protect != nil && routePerm != "" { + next = protect(next, routePerm) + } + return next + } + + mux.Handle(method+" /collectors/{collector}"+pattern, wrap(canonical, func(r *http.Request) (string, bool) { + collector := strings.TrimSpace(r.PathValue("collector")) + return collector, collector != "" + }, CanonicalCollectorRoute)) + if defaultCollector != "" { + mux.Handle(method+" "+pattern, wrap(legacy, func(*http.Request) (string, bool) { + return defaultCollector, true + }, LegacyCollectorRoute)) } } + dataRoute := func(method, pattern string, h http.Handler, routePerm, collectorPerm string) { + registerDataRoute(method, pattern, h, h, routePerm, collectorPerm) + } // ── Ingest (events:write) ──────────────────────────────────────────── if ingestPath != "" && ingestPath != "/events" && ingestPath != "/ingest" { - route("POST", ingestPath, handlers.HandleIngest, "events:write") + dataRoute("POST", ingestPath, http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write") } - route("POST", "/events", handlers.HandleIngest, "events:write") - route("POST", "/ingest", handlers.HandleIngest, "events:write") - route("POST", "/events/batch", handlers.HandleIngest, "events:write") - route("POST", "/events/ndjson", handlers.HandleIngest, "events:write") - route("POST", "/validate", handlers.HandleValidate, "schema:read") - route("POST", "/otlp/logs", handlers.HandleOTLPLogs, "logs:write") + dataRoute("POST", "/events", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write") + dataRoute("POST", "/ingest", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write") + dataRoute("POST", "/events/batch", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write") + dataRoute("POST", "/events/ndjson", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write") + dataRoute("POST", "/validate", http.HandlerFunc(handlers.HandleValidate), "schema:read", "events:read") + registerDataRoute("POST", "/otlp/logs", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandleOTLPLogs), "logs:write", "logs:write") // ── Public (no auth) ───────────────────────────────────────────────── if healthPath != "" && healthPath != "/health" && healthPath != "/healthz" { @@ -89,56 +163,74 @@ func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnab mux.HandleFunc("GET /ready", handlers.HandleReady) mux.HandleFunc("GET /readyz", handlers.HandleReady) mux.HandleFunc("GET /version", handlers.HandleVersion) - route("GET", "/status", handlers.HandleStatus, "status:read") + dataRoute("GET", "/status", http.HandlerFunc(handlers.HandleStatus), "status:read", "events:read") // ── Read endpoints ─────────────────────────────────────────────────── - route("GET", "/sinks", handlers.HandleSinks, "events:read") - route("GET", "/sinks/{name}", handlers.HandleSink, "events:read") - route("POST", "/sinks/{name}/test", handlers.HandleSinkTest, "events:write") - route("GET", "/schema", handlers.HandleSchemaList, "schema:read") - route("POST", "/schema/check", handlers.HandleSchemaCheck, "schema:read") - route("POST", "/schema/diff", handlers.HandleSchemaDiff, "schema:read") - route("POST", "/query", handlers.HandleQuery, "events:read") - route("POST", "/lql/query", handlers.HandleLQLQuery, "events:read") - route("GET", "/schema/blueprint", handlers.HandleBlueprintList, "schema:read") + dataRoute("GET", "/sinks", http.HandlerFunc(handlers.HandleSinks), "events:read", "events:read") + dataRoute("GET", "/sinks/{name}", http.HandlerFunc(handlers.HandleSink), "events:read", "events:read") + dataRoute("POST", "/sinks/{name}/test", http.HandlerFunc(handlers.HandleSinkTest), "events:write", "events:write") + dataRoute("GET", "/schema", http.HandlerFunc(handlers.HandleSchemaList), "schema:read", "events:read") + dataRoute("POST", "/schema/check", http.HandlerFunc(handlers.HandleSchemaCheck), "schema:read", "events:read") + dataRoute("POST", "/schema/diff", http.HandlerFunc(handlers.HandleSchemaDiff), "schema:read", "events:read") + dataRoute("POST", "/query", http.HandlerFunc(handlers.HandleQuery), "events:read", "events:read") + registerDataRoute( + "POST", + "/lql/query", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_raw_sql_unsupported"}) + }), + http.HandlerFunc(handlers.HandleLQLQuery), + "events:read", + "events:read", + ) + dataRoute("GET", "/schema/blueprint", http.HandlerFunc(handlers.HandleBlueprintList), "schema:read", "events:read") // ── Tail (events:read) ─────────────────────────────────────────────── - route("GET", "/tail", handlers.HandleTail, "events:read") + dataRoute("GET", "/tail", http.HandlerFunc(handlers.HandleTail), "events:read", "events:read") // ── Write endpoints ────────────────────────────────────────────────── - route("POST", "/schema/publish", handlers.HandleSchemaPublish, "schema:write") - route("POST", "/schema/blueprint", handlers.HandleBlueprintPublish, "schema:write") + dataRoute("POST", "/schema/publish", http.HandlerFunc(handlers.HandleSchemaPublish), "schema:write", "project:admin") + dataRoute("POST", "/schema/blueprint", http.HandlerFunc(handlers.HandleBlueprintPublish), "schema:write", "project:admin") // ── Replay ─────────────────────────────────────────────────────────── - route("POST", "/replay", handlers.HandleReplay, "events:write") + dataRoute("POST", "/replay", http.HandlerFunc(handlers.HandleReplay), "events:write", "events:write") // ── Admin endpoints ────────────────────────────────────────────────── - route("POST", "/audit/pii", handlers.HandlePIIAudit, "pii_audit:read") - route("POST", "/policy/validate", handlers.HandlePolicyValidate, "schema:read") - route("POST", "/retention/apply", handlers.HandleRetentionApply, "project:admin") - route("POST", "/keys", handlers.HandleKeyCreate, "project:admin") - route("POST", "/keys/{id}/revoke", handlers.HandleKeyRevoke, "project:admin") - route("DELETE", "/keys/{id}", handlers.HandleKeyRevoke, "project:admin") - route("POST", "/keys/{id}/rotate", handlers.HandleKeyRotate, "project:admin") - route("DELETE", "/events", handlers.HandleDeleteEvents, "events:delete") - route("DELETE", "/events/by-tenant/{tenant_id}", handlers.HandleDeleteEvents, "events:delete") - route("DELETE", "/events/by-user/{user_id}", handlers.HandleDeleteEvents, "events:delete") - route("DELETE", "/events/{event_id}", handlers.HandleDeleteEvents, "events:delete") + registerDataRoute("POST", "/audit/pii", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandlePIIAudit), "pii_audit:read", "project:admin") + dataRoute("POST", "/policy/validate", http.HandlerFunc(handlers.HandlePolicyValidate), "schema:read", "project:admin") + registerDataRoute("POST", "/retention/apply", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandleRetentionApply), "project:admin", "project:admin") + dataRoute("POST", "/keys", http.HandlerFunc(handlers.HandleKeyCreate), "project:admin", "project:admin") + dataRoute("POST", "/keys/{id}/revoke", http.HandlerFunc(handlers.HandleKeyRevoke), "project:admin", "project:admin") + dataRoute("DELETE", "/keys/{id}", http.HandlerFunc(handlers.HandleKeyRevoke), "project:admin", "project:admin") + dataRoute("POST", "/keys/{id}/rotate", http.HandlerFunc(handlers.HandleKeyRotate), "project:admin", "project:admin") + dataRoute("DELETE", "/events", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete") + dataRoute("DELETE", "/events/by-tenant/{tenant_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete") + dataRoute("DELETE", "/events/by-user/{user_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete") + dataRoute("DELETE", "/events/{event_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete") // ── DLQ ────────────────────────────────────────────────────────────── - route("GET", "/dlq", handlers.HandleDLQList, "events:read") - route("POST", "/dlq/replay", handlers.HandleDLQReplayAll, "events:write") - route("GET", "/dlq/{id}", handlers.HandleDLQShow, "events:read") - route("POST", "/dlq/{id}/replay", handlers.HandleDLQReplay, "events:write") - route("DELETE", "/dlq/{id}", handlers.HandleDLQDelete, "events:delete") + dataRoute("GET", "/dlq", http.HandlerFunc(handlers.HandleDLQList), "events:read", "events:read") + dataRoute("POST", "/dlq/replay", http.HandlerFunc(handlers.HandleDLQReplayAll), "events:write", "events:write") + dataRoute("GET", "/dlq/{id}", http.HandlerFunc(handlers.HandleDLQShow), "events:read", "events:read") + dataRoute("POST", "/dlq/{id}/replay", http.HandlerFunc(handlers.HandleDLQReplay), "events:write", "events:write") + dataRoute("DELETE", "/dlq/{id}", http.HandlerFunc(handlers.HandleDLQDelete), "events:delete", "events:delete") // ── WebSocket tail ─────────────────────────────────────────────────── if tailWebSocketHandler != nil { - if protect != nil { - mux.Handle("GET /ws/tail", protect(tailWebSocketHandler, "events:read")) - } else { - mux.Handle("GET /ws/tail", tailWebSocketHandler) - } + registerDataRoute( + "GET", + "/ws/tail", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_operation_unsupported"}) + }), + tailWebSocketHandler, + "events:read", + "events:read", + ) } // ── Metrics (protected by default) ─────────────────────────────────── diff --git a/collector/server/http/server_test.go b/collector/server/http/server_test.go new file mode 100644 index 0000000..d8b413a --- /dev/null +++ b/collector/server/http/server_test.go @@ -0,0 +1,147 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type routeTestHandlers struct { + scope AuthorizedCollector + seen bool +} + +func (h *routeTestHandlers) handle(w http.ResponseWriter, r *http.Request) { + h.scope, h.seen = AuthorizedCollectorFromContext(r.Context()) + w.WriteHeader(http.StatusOK) +} + +func (h *routeTestHandlers) HandleIngest(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleOTLPLogs(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleHealth(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleReady(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleVersion(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleStatus(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleValidate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSinks(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSink(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSinkTest(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSchemaList(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSchemaDiff(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSchemaCheck(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleSchemaPublish(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleBlueprintPublish(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleBlueprintList(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleQuery(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleLQLQuery(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandlePIIAudit(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandlePolicyValidate(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleRetentionApply(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleKeyCreate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleKeyRevoke(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleKeyRotate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleDeleteEvents(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleDLQList(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleDLQReplayAll(w http.ResponseWriter, r *http.Request) { + h.handle(w, r) +} +func (h *routeTestHandlers) HandleDLQShow(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleDLQReplay(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleDLQDelete(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleTail(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } +func (h *routeTestHandlers) HandleReplay(w http.ResponseWriter, r *http.Request) { h.handle(w, r) } + +func TestBuildMuxCollectorScopedAuthorization(t *testing.T) { + handlers := &routeTestHandlers{} + protect := func(next http.Handler, _ string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } + collectorProtect := func(next http.Handler, _ string, resolve CollectorResolver, mode CollectorRouteMode) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if mode == CanonicalCollectorRoute && r.Header.Get("Authorization") != "Bearer test" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + collector, ok := resolve(r) + if mode == CanonicalCollectorRoute && (!ok || collector != "orders" || r.Header.Get("X-Loza-Env") != "dev") { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r.WithContext(WithAuthorizedCollector(r.Context(), collector, "dev"))) + }) + } + mux := BuildMux("", "/health", "/ready", "/metrics", false, nil, nil, handlers, protect, collectorProtect, "") + + request := func(path string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, nil) + for name, value := range headers { + req.Header.Set(name, value) + } + response := httptest.NewRecorder() + mux.ServeHTTP(response, req) + return response + } + + response := request("/collectors/orders/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"}) + if response.Code != http.StatusOK { + t.Fatalf("authorized scoped write status = %d, want %d", response.Code, http.StatusOK) + } + if !handlers.seen || handlers.scope.Name != "orders" || handlers.scope.Environment != "dev" { + t.Fatalf("handler scope = %#v (seen %t), want authorized orders/dev", handlers.scope, handlers.seen) + } + if response = request("/collectors/payments/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"}); response.Code != http.StatusForbidden { + t.Fatalf("wrong collector status = %d, want %d", response.Code, http.StatusForbidden) + } + if response = request("/collectors/orders/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "prod"}); response.Code != http.StatusForbidden { + t.Fatalf("wrong environment status = %d, want %d", response.Code, http.StatusForbidden) + } + if response = request("/collectors/orders/events", nil); response.Code != http.StatusUnauthorized { + t.Fatalf("missing canonical credentials status = %d, want %d", response.Code, http.StatusUnauthorized) + } + if response = request("/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"}); response.Code != http.StatusNotFound { + t.Fatalf("unscoped write without default status = %d, want %d", response.Code, http.StatusNotFound) + } + + health := httptest.NewRecorder() + mux.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/health", nil)) + if health.Code != http.StatusOK { + t.Fatalf("public health status = %d, want %d", health.Code, http.StatusOK) + } +} + +func TestBuildMuxUsesLogWriteScopeForCanonicalOTLPLogs(t *testing.T) { + handlers := &routeTestHandlers{} + var requiredPermission string + collectorProtect := func(next http.Handler, permission string, _ CollectorResolver, _ CollectorRouteMode) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requiredPermission = permission + next.ServeHTTP(w, r) + }) + } + mux := BuildMux("", "/health", "/ready", "/metrics", false, nil, nil, handlers, nil, collectorProtect, "") + + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/collectors/logs/otlp/logs", nil)) + if requiredPermission != "logs:write" { + t.Fatalf("canonical OTLP log permission = %q, want logs:write", requiredPermission) + } +} diff --git a/conformance/results/collector-conformance-20260527T145952Z.json b/conformance/results/collector-conformance-20260527T145952Z.json deleted file mode 100644 index 526561d..0000000 --- a/conformance/results/collector-conformance-20260527T145952Z.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "suite": "collector-conformance", - "component": "collector", - "timestamp": "20260527T145952Z", - "results": [], - "summary": { - "total": 0, - "passed": 0, - "failed": 0 - } -} \ No newline at end of file diff --git a/conformance/run-all.sh b/conformance/run-all.sh index 340b43f..a18ad60 100644 --- a/conformance/run-all.sh +++ b/conformance/run-all.sh @@ -8,6 +8,11 @@ RESULTS_DIR="$SCRIPT_DIR/results" TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$RESULTS_DIR" +PYTHON_BIN="${PYTHON_BIN:-python3}" +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + PYTHON_BIN="python" +fi + echo "=== LOZA Conformance Suite ===" echo "Timestamp: $TIMESTAMP" echo "Results: $RESULTS_DIR" @@ -20,18 +25,18 @@ SKIPPED=0 # --- Cross-SDK Conformance via spec runner --- echo "--- Cross-SDK Conformance ---" RUNNER="$REPO_ROOT/spec/conformance/runner.py" -if command -v python3 &>/dev/null && [ -f "$RUNNER" ]; then +if command -v "$PYTHON_BIN" >/dev/null 2>&1 && [ -f "$RUNNER" ]; then SDK_CONFORMANCE_OUT="$RESULTS_DIR/sdk-conformance-${TIMESTAMP}.json" - if python3 "$RUNNER" --sdk all --verbose --output "$SDK_CONFORMANCE_OUT" 2>/dev/null; then + if "$PYTHON_BIN" "$RUNNER" --sdk all --verbose --json > "$SDK_CONFORMANCE_OUT" 2>/dev/null; then echo " PASS: sdk-conformance" - ((PASSED++)) + PASSED=$((PASSED + 1)) else echo " FAIL: sdk-conformance" - ((FAILED++)) + FAILED=$((FAILED + 1)) fi else echo " SKIP: sdk-conformance (runner not found or python3 missing)" - ((SKIPPED++)) + SKIPPED=$((SKIPPED + 1)) fi # --- Collector Sink Conformance --- @@ -40,7 +45,7 @@ if command -v go &>/dev/null; then COLL_CONFORMANCE_OUT="$RESULTS_DIR/collector-conformance-${TIMESTAMP}.json" cd "$REPO_ROOT/collector" if go test ./internal/sinks/conformance/... -json -count=1 2>/dev/null | \ - python3 -c " + "$PYTHON_BIN" -c " import sys, json results = [] passed = 0 @@ -64,34 +69,34 @@ json.dump({ }, sys.stdout, indent=2) " > "$COLL_CONFORMANCE_OUT" 2>/dev/null; then echo " PASS: collector-conformance" - ((PASSED++)) + PASSED=$((PASSED + 1)) else echo " FAIL: collector-conformance" - ((FAILED++)) + FAILED=$((FAILED + 1)) fi cd "$REPO_ROOT" else echo " SKIP: collector-conformance (go not found)" - ((SKIPPED++)) + SKIPPED=$((SKIPPED + 1)) fi # --- Python SDK Verifier (105 subchecks) --- echo "--- Python SDK Verifier ---" VERIFY="$REPO_ROOT/spec/conformance/verify.py" -if command -v python3 &>/dev/null && [ -f "$VERIFY" ]; then +if command -v "$PYTHON_BIN" >/dev/null 2>&1 && [ -f "$VERIFY" ]; then PY_VERIFY_OUT="$RESULTS_DIR/py-verify-${TIMESTAMP}.json" cd "$REPO_ROOT" - if python3 "$VERIFY" --json > "$PY_VERIFY_OUT" 2>/dev/null; then + if "$PYTHON_BIN" "$VERIFY" --json > "$PY_VERIFY_OUT" 2>/dev/null; then echo " PASS: py-verify" - ((PASSED++)) + PASSED=$((PASSED + 1)) else echo " FAIL: py-verify" - ((FAILED++)) + FAILED=$((FAILED + 1)) fi cd "$REPO_ROOT" else echo " SKIP: py-verify (verify.py not found or python3 missing)" - ((SKIPPED++)) + SKIPPED=$((SKIPPED + 1)) fi # --- Summary --- diff --git a/cortex/configs/cortex-deployment.yaml b/cortex/configs/cortex-deployment.yaml index 55cef19..0b3b49e 100644 --- a/cortex/configs/cortex-deployment.yaml +++ b/cortex/configs/cortex-deployment.yaml @@ -23,7 +23,7 @@ spec: fsGroup: 1000 containers: - name: cortex - image: ghcr.io/astraive/loza-cortex:0.2.6 + image: ghcr.io/astraive/loza-cortex:0.3.0 securityContext: capabilities: drop: diff --git a/cortex/configs/docker-compose.yml b/cortex/configs/docker-compose.yml index 3f4fbf3..1498f95 100644 --- a/cortex/configs/docker-compose.yml +++ b/cortex/configs/docker-compose.yml @@ -52,7 +52,7 @@ services: cortex-cli: profiles: ["cli"] - image: astraive/loza-cli:0.2.6 + image: astraive/loza-cli:0.3.0 environment: LOZA_CORTEX_URL: http://cortex:9312 depends_on: diff --git a/cortex/configs/k8s.yaml b/cortex/configs/k8s.yaml index 655d586..967c675 100644 --- a/cortex/configs/k8s.yaml +++ b/cortex/configs/k8s.yaml @@ -16,7 +16,7 @@ spec: spec: containers: - name: cortex - image: astraive/loza-cortex:0.2.6 + image: astraive/loza-cortex:0.3.0 ports: - containerPort: 9312 name: http diff --git a/cortex/crates/cortex-match/Cargo.lock b/cortex/crates/cortex-match/Cargo.lock index 86aad6c..45797be 100644 --- a/cortex/crates/cortex-match/Cargo.lock +++ b/cortex/crates/cortex-match/Cargo.lock @@ -110,7 +110,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cortex-match" -version = "0.2.6" +version = "0.3.0" dependencies = [ "criterion", "serde", diff --git a/cortex/crates/cortex-match/Cargo.toml b/cortex/crates/cortex-match/Cargo.toml index 370517a..7d56ff5 100644 --- a/cortex/crates/cortex-match/Cargo.toml +++ b/cortex/crates/cortex-match/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortex-match" -version = "0.2.6" +version = "0.3.0" edition = "2021" description = "High-performance incident shape matching for LOZA Cortex" authors = ["LOZA Team"] diff --git a/cortex/deploy/helm/cortex/Chart.yaml b/cortex/deploy/helm/cortex/Chart.yaml index 64e8d65..cb30e14 100644 --- a/cortex/deploy/helm/cortex/Chart.yaml +++ b/cortex/deploy/helm/cortex/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cortex description: Loza Cortex — incident reconstruction and operational memory engine type: application -version: 0.2.6 -appVersion: "0.2.6" +version: 0.3.0 +appVersion: "0.3.0" keywords: - observability - incident-response diff --git a/cortex/deploy/helm/cortex/values.yaml b/cortex/deploy/helm/cortex/values.yaml index b240915..1a854d8 100644 --- a/cortex/deploy/helm/cortex/values.yaml +++ b/cortex/deploy/helm/cortex/values.yaml @@ -9,7 +9,7 @@ replicaCount: 2 image: repository: loza-cortex - tag: "0.2.6" + tag: "0.3.0" digest: "" pullPolicy: IfNotPresent diff --git a/cortex/internal/config/config.go b/cortex/internal/config/config.go index 2c471d3..3a1afe7 100644 --- a/cortex/internal/config/config.go +++ b/cortex/internal/config/config.go @@ -59,17 +59,17 @@ type MatcherWeights struct { } type MatcherWeightsTopo struct { - Symptom float64 `yaml:"symptom"` - Temporal float64 `yaml:"temporal"` + Symptom float64 `yaml:"symptom"` + Temporal float64 `yaml:"temporal"` Resolution float64 `yaml:"resolution"` } type TemporalConfig struct { - DefaultSimilarity float64 `yaml:"default_similarity"` - SameSpeedScore float64 `yaml:"same_speed_score"` - ZeroDurationScore float64 `yaml:"zero_duration_score"` - FastThresholdMs int64 `yaml:"fast_threshold_ms"` - SlowThresholdMs int64 `yaml:"slow_threshold_ms"` + DefaultSimilarity float64 `yaml:"default_similarity"` + SameSpeedScore float64 `yaml:"same_speed_score"` + ZeroDurationScore float64 `yaml:"zero_duration_score"` + FastThresholdMs int64 `yaml:"fast_threshold_ms"` + SlowThresholdMs int64 `yaml:"slow_threshold_ms"` } // ServerConfig contains HTTP server settings @@ -93,18 +93,18 @@ type GRPCConfig struct { // StorageConfig contains database settings type StorageConfig struct { - Backend string `yaml:"backend"` - DuckDB DuckDBConfig `yaml:"duckdb"` - PostgreSQL PostgresConfig `yaml:"postgresql"` - CollectorDBPath string `yaml:"collector_db_path"` - MinSimilarityThreshold float64 `yaml:"min_similarity_threshold"` - Similarity StorageSimilarityConfig `yaml:"similarity"` + Backend string `yaml:"backend"` + DuckDB DuckDBConfig `yaml:"duckdb"` + PostgreSQL PostgresConfig `yaml:"postgresql"` + CollectorDBPath string `yaml:"collector_db_path"` + MinSimilarityThreshold float64 `yaml:"min_similarity_threshold"` + Similarity StorageSimilarityConfig `yaml:"similarity"` } type StorageSimilarityConfig struct { - ShapePartial float64 `yaml:"shape_partial"` - ShapeMismatch float64 `yaml:"shape_mismatch"` - Weights SimilarityWeights `yaml:"weights"` + ShapePartial float64 `yaml:"shape_partial"` + ShapeMismatch float64 `yaml:"shape_mismatch"` + Weights SimilarityWeights `yaml:"weights"` } type SimilarityWeights struct { @@ -171,28 +171,28 @@ type IngestionConfig struct { // ReconstructorConfig contains reconstruction settings type ReconstructorConfig struct { - Fast ReconstructionMode `yaml:"fast"` - Deep ReconstructionMode `yaml:"deep"` - Confidence ConfidenceConfig `yaml:"confidence"` - Graph ReconstructorGraphConfig `yaml:"graph"` - Explain ExplainConfig `yaml:"explain"` - SignalScore SignalScoreConfig `yaml:"signal_score"` + Fast ReconstructionMode `yaml:"fast"` + Deep ReconstructionMode `yaml:"deep"` + Confidence ConfidenceConfig `yaml:"confidence"` + Graph ReconstructorGraphConfig `yaml:"graph"` + Explain ExplainConfig `yaml:"explain"` + SignalScore SignalScoreConfig `yaml:"signal_score"` } type ReconstructionMode struct { - MaxDepth int `yaml:"max_depth"` - MaxEvents int `yaml:"max_events"` + MaxDepth int `yaml:"max_depth"` + MaxEvents int `yaml:"max_events"` TimeWindow time.Duration `yaml:"time_window"` } type ConfidenceConfig struct { - Base float64 `yaml:"base"` - CausalChainBonus float64 `yaml:"causal_chain_bonus"` - SymptomBonus float64 `yaml:"symptom_bonus"` - SimilarityWeight float64 `yaml:"similarity_weight"` - RemediationWeight float64 `yaml:"remediation_weight"` - MaxConfidence float64 `yaml:"max_confidence"` - MinConfidence float64 `yaml:"min_confidence"` + Base float64 `yaml:"base"` + CausalChainBonus float64 `yaml:"causal_chain_bonus"` + SymptomBonus float64 `yaml:"symptom_bonus"` + SimilarityWeight float64 `yaml:"similarity_weight"` + RemediationWeight float64 `yaml:"remediation_weight"` + MaxConfidence float64 `yaml:"max_confidence"` + MinConfidence float64 `yaml:"min_confidence"` } type ReconstructorGraphConfig struct { @@ -200,8 +200,8 @@ type ReconstructorGraphConfig struct { } type ExplainConfig struct { - MaxKeyFindings int `yaml:"max_key_findings"` - MaxAlternatives int `yaml:"max_alternatives"` + MaxKeyFindings int `yaml:"max_key_findings"` + MaxAlternatives int `yaml:"max_alternatives"` } type SignalScoreConfig struct { @@ -229,7 +229,7 @@ type MemoryConfig struct { // LearnerConfig contains continuous learning settings type LearnerConfig struct { - LearningRate float64 `yaml:"learning_rate"` + LearningRate float64 `yaml:"learning_rate"` FeatureWeightMin float64 `yaml:"feature_weight_min"` FeatureWeightMax float64 `yaml:"feature_weight_max"` } diff --git a/cortex/internal/middleware/auth.go b/cortex/internal/middleware/auth.go index bb50bb7..2b2b83c 100644 --- a/cortex/internal/middleware/auth.go +++ b/cortex/internal/middleware/auth.go @@ -15,16 +15,16 @@ import ( ) type Auth struct { - cfg *config.AuthenticationConfig - keyCache map[string]*hashedAPIKey + cfg *config.AuthenticationConfig + keyCache map[string]*hashedAPIKey hmacSecret []byte - once sync.Once + once sync.Once } type hashedAPIKey struct { - Name string - KeyHash []byte - Role string + Name string + KeyHash []byte + Role string } // AutoGenerateHMACSecret derives a deterministic HMAC key from the configured API keys. @@ -80,9 +80,9 @@ func hmacSHA256(data, secret []byte) []byte { type AuthResult struct { Authorized bool - KeyName string - Role string - Failure string + KeyName string + Role string + Failure string FailureCode string } @@ -254,11 +254,11 @@ func logAuthJSON(level, event string, fields map[string]interface{}) { func (a *Auth) logAuthFailure(r *http.Request, keyName, reason, code string) { logAuthJSON("warn", "auth_failure", map[string]interface{}{ - "key_name": keyName, + "key_name": keyName, "path": r.URL.Path, "method": r.Method, "remote_addr": r.RemoteAddr, "reason": reason, "failure_code": code, }) -} \ No newline at end of file +} diff --git a/cortex/loza-cortex.yaml b/cortex/loza-cortex.yaml index 92f1b79..dab4658 100644 --- a/cortex/loza-cortex.yaml +++ b/cortex/loza-cortex.yaml @@ -1,6 +1,6 @@ name: loza-cortex kind: docker -version: 0.2.6 +version: 0.3.0 description: Incident intelligence and graph analysis engine for LOZA license: MIT repository: https://github.com/Astraive/loza diff --git a/docs/DSN.md b/docs/DSN.md index ae0e7c6..20bfc4f 100644 --- a/docs/DSN.md +++ b/docs/DSN.md @@ -6,9 +6,14 @@ It resolves to HTTP/HTTPS/OTLP/gRPC/WebSocket endpoints -- it is NOT a new wire ## Format ``` -loza://[host][:port]/[project]?env=&service=&tls=&transport= +loza://[[:]@][:port]/?env=&service=&tls=&transport= ``` +The required path identifies one named Collector resource. The optional userinfo +carries either private Basic credentials (`username:password`) or a public +opaque access ID (`lx_pub_...` with no password). A DSN without userinfo is +unchanged for API-key-based configurations. + ## Examples ```bash @@ -20,17 +25,27 @@ LOZA_API_KEY=lx_sec_dev_k_xxx LOZA_DSN=loza://collector.example.com/payments?env=prod LOZA_API_KEY=lx_sec_prod_k_xxx +# Private credentialed DSN (percent-encode reserved password characters) +LOZA_DSN=loza://payments-writer:s%40cret%3Avalue@collector.example.com/payments?env=prod + +# Public, revocable opaque access ID with no password +LOZA_DSN=loza://lx_pub_@collector.example.com/payments?env=prod + # OTLP transport for staging LOZA_DSN=loza://loza.internal:4318/backend?env=staging&service=auth&transport=otlp ``` +The private username is the Collector configured grant username and its password is the grant secret. A public username MUST be an opaque high-entropy `lx_pub_...` access ID; it is a bearer capability, not a human name. SDK parsers percent-decode private credentials. Empty private components and username `:`/whitespace are invalid; URL-reserved private password characters must be percent-encoded. + ## Parameters | Parameter | Required | Default | Description | |------------|----------|--------------|--------------------------------------------------| +| `username` | With userinfo | -- | Private grant username or opaque `lx_pub_...` public access ID | +| `password` | Private identity | -- | Secret for a private Collector grant; percent-encoded | | `host` | Yes | -- | Collector hostname | | `port` | No | See below | Collector port | -| `project` | Yes | -- | Project name (path segment) | +| `collector-name` | Yes | -- | Named Collector resource (path segment) | | `env` | No | `"default"` | Deployment environment | | `service` | No | `""` | Service name | | `tls` | No | See below | `true`, `false`, or `auto` | @@ -57,29 +72,47 @@ Setting `tls=auto` preserves the computed default. ## Resolved URLs -A parsed `loza://` DSN resolves to these endpoints: +A parsed `loza://` DSN resolves to credential-free, collector-scoped endpoints: | Field | Pattern | Example | |--------------|-----------------------------------|------------------------------------------| | `BaseURL` | `http(s)://host:port` | `https://collector.example.com:443` | -| `EventsURL` | `BaseURL + /events` | `https://collector.example.com:443/events` | -| `BatchURL` | `BaseURL + /events/batch` | `https://collector.example.com:443/events/batch` | -| `OTLPURL` | `BaseURL + /otlp/logs` | `https://collector.example.com:443/otlp/logs` | -| `TailWSURL` | `ws(s)://host:port/tail` | `wss://collector.example.com:443/tail` | +| `EventsURL` | `BaseURL + /collectors/{collector}/events` | `https://collector.example.com:443/collectors/payments/events` | +| `BatchURL` | `BaseURL + /collectors/{collector}/events/batch` | `https://collector.example.com:443/collectors/payments/events/batch` | +| `OTLPURL` | `BaseURL + /collectors/{collector}/otlp/logs` | `https://collector.example.com:443/collectors/payments/otlp/logs` | +| `TailWSURL` | `ws(s)://host:port/collectors/{collector}/tail` | `wss://collector.example.com:443/collectors/payments/tail` | + +## Security and Authentication -## Security +- A private credentialed DSN sends `Authorization: Basic `; SDKs use TLS by default. +- A public DSN sends `Authorization: Basic :)>`. Its opaque public access ID is a bearer capability and MUST be redacted and protected like a secret. +- Plain HTTP with any credential is rejected by SDK configuration validation unless explicitly local (`tls=false`/insecure). The parser may parse the URI, but it must not silently enable production plaintext HTTP. +- Do not put credentials in query parameters or paths. Resolved `BaseURL`, `EventsURL`, `BatchURL`, `OTLPURL`, and `TailWSURL` never contain userinfo, passwords, or access IDs. +- Normal DSN string/debug representations must be redacted; never log private passwords or public access IDs. Use a secret manager/environment variable rather than committing a credentialed DSN. -- **Do NOT put API keys in the DSN URL.** Use `LOZA_API_KEY` environment variable separately. -- `loza://` resolves to HTTP/HTTPS, not a custom protocol. -- Userinfo in the URL (`loza://key@host/project`) is explicitly rejected. +## Credential Precedence + +Credential sources are applied with this precedence: + +1. Explicit code API key or Basic credentials. +2. Credentials in an explicitly supplied code DSN. +3. Environment credential sources (`LOZA_API_KEY` and userinfo in `LOZA_DSN`). + +`LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` overrides only the endpoint; it does not replace DSN-derived environment, service, or credentials. A DSN without userinfo never clears credentials configured separately. ## Environment Variable Usage ```bash -# Go SDK +# Go SDK — existing no-userinfo/API-key form export LOZA_DSN=loza://collector.example.com/my-app?env=prod export LOZA_API_KEY=lx_sec_prod_k_xxx +# Private Basic-auth form; keep this value in a secret environment/secret manager +export LOZA_DSN='loza://payments-writer:s%40cret%3Avalue@collector.example.com/my-app?env=prod' + +# Public opaque access ID; it is still sensitive and should be stored as a secret +export LOZA_DSN='loza://lx_pub_@collector.example.com/my-app?env=prod' + # JS/TS SDK LOZA_DSN=loza://collector.example.com/my-app?env=prod LOZA_API_KEY=lx_sec_prod_k_xxx @@ -96,10 +129,13 @@ LOZA_API_KEY=lx_sec_prod_k_xxx | `http://host/project` | Scheme must be `loza://` | | `loza://` | Host is required | | `loza:///project` | Host is required | -| `loza://host` | Project path is required | -| `loza://host/` | Project path is required | -| `loza://key@host/project` | Do not put API keys in the URL | -| `loza://host/project?tls=maybe` | tls must be true, false, or auto | +| `loza://host` | Collector name path is required | +| `loza://host/` | Collector name path is required | +| `loza://host/collector` | Valid no-userinfo DSN | +| `loza://writer:@host/collector` | Private username and password are required | +| `loza://:secret@host/collector` | Private username and password are required | +| `loza://lx_pub_@host/collector` | Valid public opaque access ID DSN | +| `loza://user@host/collector` | Public access ID must begin `lx_pub_` | | `loza://host/project?transport=x` | transport must be http, otlp, or grpc | | `loza://host:0/project` | Invalid port | | `loza://host:99999/project` | Invalid port | diff --git a/docs/authentication.md b/docs/authentication.md index ac01476..197ea39 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,12 +2,24 @@ ## Overview -LOZA uses scoped API keys with the `Authorization: Bearer` header for all ingest and control-plane requests. Keys follow the `lx_` format and carry built-in RBAC roles and ABAC restrictions. +LOZA supports scoped API keys and opaque bearer tokens with the `Authorization: Bearer` header, plus credentialed DSNs with HTTP Basic authentication. API keys and tokens resolve an RBAC role server-side; credentialed DSNs supply the Collector key ID and its secret. -``` +Bearer example: + +```http Authorization: Bearer lx_sec_live_k2M9aQp_7QmVxN8pT4zRbK1sYw ``` +Credentialed DSN example: + +```text +loza://kingest:s%40cret%3Avalue@collector.example.com/payments?env=prod +``` + +In this PostgreSQL-style form, `username` is the Collector configured `key_id` and `password` is that key's secret. SDKs percent-decode both values; empty values are invalid, username `:`/whitespace is invalid, and URL-reserved password characters must be percent-encoded. SDKs send these credentials as `Authorization: Basic base64(username:password)` over TLS by default. Plain HTTP with credentials is rejected during SDK configuration unless explicitly local (`tls=false`/insecure). + +Credentialed DSN passwords are never included in resolved endpoint URLs or normal DSN/debug output, and must never be logged. Use a secret environment variable or secret manager for DSNs and do not commit them. + ## Key Format ``` @@ -69,9 +81,27 @@ For local development. Full ingest permissions but blocked in production environ lx_local_dev_mydevtoken ``` +## RBAC roles and credential modes + +| Role | Log permissions | +| --- | --- | +| `user` | `logs:read` | +| `client` | `logs:read`, `logs:write` | +| `admin` | `logs:read`, `logs:write`, `logs:edit`, `logs:delete` | +| `superadmin` | all `admin` log permissions plus `project:admin` | + +Credentials use `mode: private` or `mode: public`. Private credentials can +use any recognized role. Public credentials require an origin allowlist and +are limited to the least-privileged `client` role; never assign `admin` or +`superadmin` to a browser-exposed credential. + +An opaque token is configured by environment variable and sent verbatim as a +Bearer value. LOZA derives its lookup ID and its stored HMAC from the token, +so the raw value is neither persisted as an identifier nor emitted in logs. + ## SDK Configuration -Set the `LOZA_API_KEY` environment variable, then configure the SDK: +Set `LOZA_API_KEY` for the existing Bearer-token flow, or set `LOZA_DSN` to a credentialed DSN for Basic auth. Explicit SDK credentials can be supplied in code as shown below. ### Go @@ -163,19 +193,35 @@ const logger = createLoza({ }); ``` +## Credential Precedence + +Credential sources are applied in this order: + +1. Explicit code API key or Basic credentials. +2. Credentials in an explicitly supplied code DSN. +3. Environment credentials (`LOZA_API_KEY` and userinfo in `LOZA_DSN`). + +`LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` changes only the endpoint; it does not override DSN-derived environment, service, or credentials. A DSN without userinfo does not clear credentials configured separately. + ## Headers -When an API key is configured, the SDK automatically sends these headers: +When an API key is configured, the SDK sends Bearer authentication. When a credentialed DSN is configured, it sends Basic authentication from the DSN userinfo: | Header | Value | Description | |--------|-------|-------------| -| `Authorization` | `Bearer lx_sec_live_k_xxx_yyyy` | The API key | +| `Authorization` | `Bearer lx_sec_live_k_xxx_yyyy` | Existing API-key authentication | +| `Authorization` | `Basic ` | Credentialed DSN authentication | | `X-Loza-Service` | `checkout-api` | Service name from config | | `X-Loza-Env` | `prod` | Environment from config | +Basic credentials are sent over TLS by default. Never put the password in query parameters, paths, resolved endpoint URLs, logs, or unredacted DSN/debug output. + ## Collector Configuration -The Collector validates configured key records using RBAC and ABAC. `server_secret` is an independent HMAC key; it is not the at-rest storage key. `secret_env` names hold only the token secret, so a configured key is presented as `lx_{kind}_live_{key_id}_${SECRET_ENV}`. +The Collector validates configured key records using RBAC and ABAC. +`server_secret` is an independent HMAC key; it is not the at-rest storage key. +`secret_env` holds only the token secret, so a configured key is presented as +`lx_{kind}_live_{key_id}_${SECRET_ENV}`. ```yaml auth: @@ -183,28 +229,104 @@ auth: server_secret: "${COLLECTOR_AUTH_SERVER_SECRET}" cache_ttl: 5m negative_cache_ttl: 30s + default_collector: payments # legacy root routes only + collectors: + - slug: payments keys: - - name: ingest - key_id: kingest - secret_env: COLLECTOR_INGEST_KEY_SECRET + - name: payments-operator + key_id: kpaymentsoperator + secret_env: PAYMENTS_OPERATOR_KEY_SECRET kind: sec - roles: [collector_ingest_server] - - name: administrator - key_id: kadmin - secret_env: COLLECTOR_ADMIN_KEY_SECRET + collector: payments + permissions: [events:read, events:write, events:delete] + allowed_envs: [prod, staging] + - name: payments-admin + key_id: kpaymentsadmin + secret_env: PAYMENTS_ADMIN_KEY_SECRET kind: sec - roles: [project_admin] - - name: browser - key_id: collector_browser - secret_env: COLLECTOR_BROWSER_KEY_SECRET + collector: payments + permissions: [project:admin] + allowed_envs: [prod] + - name: payments-browser + key_id: kpaymentsbrowser + secret_env: PAYMENTS_BROWSER_KEY_SECRET kind: pub - roles: [collector_ingest_public] + collector: payments + permissions: [events:write] + allowed_envs: [prod] allowed_origins: [https://app.example.com] storage: encryption_key_env: LOZA_STORAGE_ENCRYPTION_KEY ``` -All configured key IDs must be unique. `kind` is `sec` or `pub`; public keys require a non-empty origin allowlist. Auth startup requires a non-empty resolved server secret, every configured key secret, and the storage encryption key. An explicit `auth.enabled: false` remains an operator override, not a tracked default. +All configured key IDs must be unique. `kind` is `sec` or `pub`; public keys +require a non-empty origin allowlist. A scoped API key MUST supply `collector`, +`permissions`, and non-empty `allowed_envs`; the collector must be listed in +`auth.collectors`. Its permissions are evaluated only against the canonical +`/collectors/{collector}/...` resource and default to deny. Unscoped keys are +supported solely for legacy root routes during migration. Auth startup requires +a non-empty resolved server secret, every configured key secret, and the +storage encryption key. An explicit `auth.enabled: false` remains an operator +override, not a tracked default. + +## Multi-Collector Access + +Each DSN path identifies one Collector resource. SDKs send events to the +canonical scoped resource path: + +```text +loza://kingest:s%40cret@collector.example.com/payments?env=prod + └──────── collector name +``` + +The resolved ingest URL is +`https://collector.example.com/collectors/payments/events`; userinfo is never +included in that URL. + +API keys and Collector grants both bind an authenticated credential to a named +collector, explicit environments, and permissions. The Collector defaults to +deny when no binding matches the route collector, `X-Loza-Env`, and required +permission. `project:admin` manages the named collector; `events:read`, +`events:write`, and `events:delete` remain distinct. + +```yaml +auth: + default_collector: payments # temporary legacy /events compatibility only + collectors: + - slug: payments + - slug: analytics + grants: + - name: payments-ingest + collector: payments + username: payments-writer + password_env: PAYMENTS_WRITER_PASSWORD + permissions: [events:write] + allowed_envs: [prod, staging] + - name: payments-browser + collector: payments + public_id_env: PAYMENTS_BROWSER_ACCESS_ID + permissions: [events:write] + allowed_envs: [prod] + allowed_origins: [https://app.example.com] +``` + +Private grants require `username:password` and a password sourced from a +secret environment variable. A public DSN uses an opaque, high-entropy +`lx_pub_...` access ID as its username and no password: + +```text +loza://lx_pub_@collector.example.com/payments?env=prod +``` + +Despite appearing in the username position, a public access ID is a revocable +bearer capability. Store it in a secret manager, redact it in logs and DSN +representations, constrain it by collector/environment/origin/IP/rate limit, +and rotate it like any other credential. A human-readable username without a +password is forgeable and is not a valid authorization identity. + +Unscoped legacy routes such as `/events` work only when `default_collector` is +configured. New clients and integrations MUST use `/collectors/{collector}/...` +and MUST NOT rely on event payload fields to choose collector or environment. ## Token Storage Security @@ -221,10 +343,11 @@ hash = hmac_sha256(server_secret, token_secret) ## Key Rotation -1. Create a new key in the dashboard (or control plane API) -2. Deploy your application with the new key -3. The collector accepts both keys during the overlap window -4. Revoke the old key after all deployments are updated +1. Create a new key in the dashboard (or control plane API). +2. Store the new secret in the application secret environment/secret manager; if using a DSN, percent-encode reserved password characters. +3. Deploy your application with the new key or DSN. +4. The collector accepts both keys during the overlap window. +5. Revoke the old key after all deployments are updated. ## Legacy Bootstrap Token diff --git a/docs/configuration.md b/docs/configuration.md index f72d9ca..818900c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,9 @@ collector: ### `auth` -API-key authentication uses `Authorization: Bearer` and is enabled in every tracked Collector configuration. `server_secret` is used only for HMAC key-record hashing and must not be reused as the storage encryption key. +API-key authentication uses `Authorization: Bearer` and is enabled in every +tracked Collector configuration. `server_secret` is used only for HMAC +key-record hashing and must not be reused as the storage encryption key. ```yaml auth: @@ -70,24 +72,62 @@ auth: server_secret: "${COLLECTOR_AUTH_SERVER_SECRET}" cache_ttl: 5m negative_cache_ttl: 30s + collectors: + - slug: checkout keys: - - name: ingest - key_id: kingest - secret_env: COLLECTOR_INGEST_KEY_SECRET + - name: checkout-writer + key_id: kcheckoutwriter + secret_env: CHECKOUT_WRITER_KEY_SECRET kind: sec + mode: private roles: [collector_ingest_server] - - name: administrator - key_id: kadmin - secret_env: COLLECTOR_ADMIN_KEY_SECRET + collector: checkout + permissions: [events:write] + allowed_envs: [prod] + - name: checkout-reader + key_id: kcheckoutreader + secret_env: CHECKOUT_READER_KEY_SECRET kind: sec - roles: [project_admin] + mode: private + roles: [project_readonly] + collector: checkout + permissions: [events:read] + allowed_envs: [prod] +``` + +`secret_env` is the secret segment of +`lx_{kind}_live_{key_id}_{secret}`, not the full token. Key IDs are unique; +`kind` must be `sec` or `pub`; `mode` is `private` or `public`; public +credentials require `allowed_origins` and may use only the `client` role. +Scoped keys require a configured `collector`, non-empty `permissions`, and +non-empty `allowed_envs`. Scoped permissions are exact: +`events:read`, `events:write`, `events:delete`, `logs:read`, `logs:write`, +`logs:edit`, `logs:delete`, and `project:admin` are authorized independently +on `/collectors/{collector}/...`. Unscoped keys remain limited to explicitly +configured legacy root routes. + +Opaque Bearer tokens use the same RBAC and scope model: + +```yaml +auth: + tokens: + - name: browser-log-writer + token_env: BROWSER_LOG_TOKEN + mode: public + roles: [client] + collector: checkout + permissions: [logs:write] + allowed_envs: [prod] + allowed_origins: [https://app.example.com] ``` -`secret_env` is the secret segment of `lx_{kind}_live_{key_id}_{secret}`, not the full token. Key IDs are unique; `kind` must be `sec` or `pub`; every role must be a supported Collector role; and public keys require `allowed_origins`. +`token_env` must contain an opaque value, not an `lx_` API key. The raw token +is only accepted in `Authorization: Bearer`; it is not used as a stored ID. ```bash -curl -X POST http://localhost:9308/events \ - -H "Authorization: Bearer lx_sec_live_kingest_${COLLECTOR_INGEST_KEY_SECRET}" \ +curl -X POST http://localhost:9308/collectors/checkout/events \ + -H "Authorization: Bearer lx_sec_live_kcheckoutwriter_${CHECKOUT_WRITER_KEY_SECRET}" \ + -H "X-Loza-Env: prod" \ -H 'Content-Type: application/json' -d '[{"event":"example"}]' curl http://localhost:9308/health ``` diff --git a/docs/ports.md b/docs/ports.md index b92dcde..64502b1 100644 --- a/docs/ports.md +++ b/docs/ports.md @@ -41,7 +41,7 @@ ## Configuration -Loza uses a layered config system. All non-secret values can be set via YAML files, environment variables, or code. Secrets must come from environment variables. +Loza uses a layered config system. Non-secret values can be set via YAML files, environment variables, or code. Prefer secret environment variables or a secret manager for credentials. A credentialed `LOZA_DSN` may carry percent-encoded Basic-auth userinfo, but it must not be committed or logged. ### 1. Defaults (committed) @@ -94,7 +94,7 @@ docker run -v /host/config.yaml:/etc/loza/config.yaml \ ### 3. Environment variables -All config values can be overridden via env vars. Secrets must use env vars. +All config values can be overridden via env vars. Keep API keys and DSN credentials in environment variables or a secret manager. ```bash # Non-secret overrides @@ -102,15 +102,19 @@ COLLECTOR_SERVER_PORT=9308 CORTEX_SERVER_PORT=9312 LOZA_ENVIRONMENT=production -# Secrets (env vars only) +# Secrets LOZA_API_KEY=lx_sec_live_xxx +# Optional Basic-auth form; percent-encode reserved password characters +LOZA_DSN='loza://kingest:s%40cret%3Avalue@collector.example.com/my-app?env=prod' LOZA_STORAGE_ENCRYPTION_KEY=xxx CORTEX_POSTGRES_PASSWORD=xxx ``` ### 4. Code (SDKs) -Highest precedence — overrides everything. +Credential precedence is: explicit code API key or Basic credentials, then credentials in an explicitly supplied code DSN, then environment credentials (`LOZA_API_KEY` or userinfo in `LOZA_DSN`). `LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` overrides only the endpoint and does not replace DSN-derived environment, service, or credentials. A DSN without userinfo does not clear separately configured credentials. + +Explicit code configuration otherwise remains the highest-precedence source for the fields it sets: ```go loza.Configure(loza.Production("checkout"). @@ -145,4 +149,6 @@ loza://localhost/my-app → http://localhost:9308 loza://localhost:9999/my-app → http://localhost:9999 ``` +Credentialed DSNs use `loza://username:password@host/project`. The username is the Collector `key_id`; the password is its secret. SDKs send Basic auth over TLS by default and reject plaintext HTTP with credentials unless explicitly local (`tls=false`/insecure). Resolved endpoint URLs and logs never contain the password. + Set via env var: `LOZA_DSN=loza://localhost/my-app` diff --git a/examples/quickstart/go/go.mod b/examples/quickstart/go/go.mod index c794ac1..058fee8 100644 --- a/examples/quickstart/go/go.mod +++ b/examples/quickstart/go/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require github.com/astraive/loza/sdks/go v0.2.1 require ( - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/examples/quickstart/rs/src/main.rs b/examples/quickstart/rs/src/main.rs index e26bf8c..86e106f 100644 --- a/examples/quickstart/rs/src/main.rs +++ b/examples/quickstart/rs/src/main.rs @@ -5,12 +5,17 @@ fn main() { loza::info("server started"); - let ctx = loza::start_event(loza::Params::new("user.signup").with_kind("http")); - loza::enrich(&ctx, "user.email", "demo@example.com"); - loza::enrich(&ctx, "user.plan", "pro"); - loza::finish(&ctx, "success"); - match loza::emit(&ctx) { - Ok(result) => println!("Event emitted: {result}"), + let mut ctx = loza::start_event(None, loza::Params::new("user.signup").with_kind("http")); + loza::enrich( + &mut ctx, + [ + loza::String("user.email", "demo@example.com"), + loza::String("user.plan", "pro"), + ], + ); + loza::finish(&mut ctx); + match loza::emit(&mut ctx) { + Ok(()) => println!("Event emitted"), Err(e) => eprintln!("emit error: {e}"), } diff --git a/go.work b/go.work new file mode 100644 index 0000000..fb1196d --- /dev/null +++ b/go.work @@ -0,0 +1,18 @@ +go 1.25.13 + +use ( + ./cli + ./collector + ./cortex + ./examples/quickstart/go + ./gen/go + ./sdks/go + ./sdks/go/bench + ./sdks/go/examples/httpbatch-to-collector + ./sdks/go/src/integrations + ./sdks/go/src/middleware + ./sdks/go/tests/e2e + ./sdks/go/tests/integration + ./spec + ./tools/stress +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..779b86e --- /dev/null +++ b/go.work.sum @@ -0,0 +1,20 @@ +cloud.google.com/go/compute v1.54.0 h1:4CKmnpO+40z44bKG5bdcKxQ7ocNpRtOc9SCLLUzze1w= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.11.4 h1:4ayjakA013OdpGyL2K3ZqylTac/rMjrJOMZ1EHizXas= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401001100-f93e5f3e9f0f/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= diff --git a/loza.yaml b/loza.yaml index 7cb29f2..0b029ba 100644 --- a/loza.yaml +++ b/loza.yaml @@ -1,6 +1,6 @@ name: loza kind: release -version: 0.2.6 +version: 0.3.0 description: Umbrella LOZA repository release license: MIT repository: https://github.com/Astraive/loza diff --git a/lql/Cargo.lock b/lql/Cargo.lock index 89f24bf..e8fbaa6 100644 --- a/lql/Cargo.lock +++ b/lql/Cargo.lock @@ -398,7 +398,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loza-lql" -version = "0.2.6" +version = "0.3.0" dependencies = [ "criterion", "insta", diff --git a/lql/Cargo.toml b/lql/Cargo.toml index 09b4d8b..782f2e2 100644 --- a/lql/Cargo.toml +++ b/lql/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loza-lql" -version = "0.2.6" +version = "0.3.0" edition = "2021" description = "Loza Query Language — compiles to DuckDB/ClickHouse SQL" license = "MIT" diff --git a/lql/lql.yaml b/lql/lql.yaml index 9a16a50..d74e7b0 100644 --- a/lql/lql.yaml +++ b/lql/lql.yaml @@ -1,7 +1,7 @@ name: loza-lql kind: package language: rust -version: 0.2.6 +version: 0.3.0 description: LOZA Query Language compiler and tooling license: MIT repository: https://github.com/Astraive/loza diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 28e1c01..9f46049 100644 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -42,6 +42,7 @@ FILES=( # JS SDK — package.json "version": "X.Y.Z" "sdks/js/package.json" + "sdks/js/package-lock.json" # LQL — Cargo.toml version = "X.Y.Z" "lql/Cargo.toml" @@ -53,6 +54,9 @@ FILES=( "cortex/deploy/helm/cortex/Chart.yaml" "collector/deploy/helm/loza/Chart.yaml" + + # Umbrella release metadata + "loza.yaml" # Project metadata YAML "collector/loza.yaml" "cortex/loza-cortex.yaml" @@ -60,6 +64,7 @@ FILES=( "sdks/go/loza-go.yaml" "sdks/py/loza-py.yaml" "sdks/rs/loza-rs.yaml" + "spec/docs/sdk-parity-manifest.json" "sdks/js/loza-js.yaml" "spec/loza-spec.yaml" "lql/lql.yaml" @@ -75,6 +80,10 @@ FILES=( "cortex/configs/cortex-deployment.yaml" "cortex/configs/k8s.yaml" "collector/deploy/k8s/collector-deployment.yaml" + + # Chart default image tags + "collector/deploy/helm/loza/values.yaml" + "cortex/deploy/helm/cortex/values.yaml" ) # --------------------------------------------------------------------------- diff --git a/scripts/release/test_validate_manifests.py b/scripts/release/test_validate_manifests.py new file mode 100644 index 0000000..8951a1f --- /dev/null +++ b/scripts/release/test_validate_manifests.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("validate-manifests.py") +SPEC = importlib.util.spec_from_file_location("validate_manifests", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +validate_manifests = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validate_manifests) + + +class JavaScriptPackageIdentityTests(unittest.TestCase): + def test_scoped_package_metadata_matches_native_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + package_root = Path(temporary_directory) + (package_root / "package.json").write_text( + json.dumps({"name": "@astraive/loza", "version": "0.3.0"}), + encoding="utf-8", + ) + manifest = { + "kind": "sdk", + "language": "javascript", + "version": "0.3.0", + "paths": {"root": package_root.as_posix()}, + "publish": {"npm": {"owner": "astraive", "package": "@astraive/loza"}}, + } + + native_errors = validate_manifests.validate_native_metadata("sdk-js", manifest, package_root / "loza-js.yaml") + publish_errors = validate_manifests.validate_publish_metadata("sdk-js", manifest, package_root / "loza-js.yaml") + + self.assertEqual(native_errors, []) + self.assertEqual(publish_errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/validate-manifests.py b/scripts/release/validate-manifests.py index b23f7a7..a8c31f0 100644 --- a/scripts/release/validate-manifests.py +++ b/scripts/release/validate-manifests.py @@ -24,6 +24,7 @@ KNOWN_KINDS = {"spec", "docker", "cli", "sdk", "package", "release"} KNOWN_LANGUAGES = {"go", "javascript", "python", "rust"} PUBLISH_OWNER = "astraive" +NPM_PACKAGE = "@astraive/loza" def load_yaml(path: Path) -> dict[str, Any]: @@ -61,8 +62,8 @@ def validate_native_metadata(component: str, manifest: dict[str, Any], manifest_ package_json = root / "package.json" if package_json.exists(): package_data = json.loads(package_json.read_text(encoding="utf-8")) - if package_data.get("name") != "loza": - errors.append(f"{component}: sdks/js/package.json name must be 'loza', not {package_data.get('name')!r}") + if package_data.get("name") != NPM_PACKAGE: + errors.append(f"{component}: sdks/js/package.json name must be {NPM_PACKAGE!r}, not {package_data.get('name')!r}") if package_data.get("version") != version: errors.append(f"{component}: sdks/js/package.json version must match manifest version {version}") @@ -154,8 +155,8 @@ def validate_publish_metadata(component: str, manifest: dict[str, Any], manifest npm = publish.get("npm") if not isinstance(npm, dict): errors.append(f"{component}: JavaScript SDK requires publish.npm") - elif npm.get("package") != "loza": - errors.append(f"{component}: npm package must be 'loza', not {npm.get('package')!r}") + elif npm.get("package") != NPM_PACKAGE: + errors.append(f"{component}: npm package must be {NPM_PACKAGE!r}, not {npm.get('package')!r}") elif npm.get("owner") != PUBLISH_OWNER: errors.append(f"{component}: publish.npm.owner must be '{PUBLISH_OWNER}'") if language == "python": diff --git a/sdks/go/.env.example b/sdks/go/.env.example index e48e5d7..1b9e0de 100644 --- a/sdks/go/.env.example +++ b/sdks/go/.env.example @@ -2,11 +2,13 @@ # Copy to .env and fill in values # Connection -# LOZA_DSN=loza://localhost:9308 # loza:// connection URI (sets CollectorURL, Environment, Service) -# LOZA_COLLECTOR_URL=http://localhost:9308 # Collector endpoint URL (overrides DSN) +# LOZA_DSN=loza://localhost:9308/my-app # No-userinfo DSN; sets endpoint and DSN metadata +# LOZA_DSN=loza://kingest:s%40cret%3Avalue@collector.example.com/my-app?env=prod +# # Optional Basic auth: key_id:percent-encoded secret +# LOZA_COLLECTOR_URL=http://localhost:9308 # Endpoint override; does not clear DSN metadata/credentials # Authentication -# LOZA_API_KEY= # Ingest API key (e.g. lx_sec_live_k_xxx_yyyy) +# LOZA_API_KEY= # Bearer token (e.g. lx_sec_live_k_xxx_yyyy); highest-priority token credential # Service Identity # LOZA_SERVICE_NAME=my-service # Service name (required in strict mode) diff --git a/sdks/go/README.md b/sdks/go/README.md index de730b2..5b44643 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/astraive/loza/actions/workflows/sdks-go-ci.yml/badge.svg)](https://github.com/astraive/loza/actions/workflows/sdks-go-ci.yml) -**Status**: 🟢 **STABLE** (v0.2.6) - Production-ready, collector-first stable-v1 SDK +**Status**: 🟢 **STABLE** (v0.3.0) - Production-ready, collector-first stable-v1 SDK Full emitter SDK conformance is tracked through `spec/`: diff --git a/sdks/go/bench/go.mod b/sdks/go/bench/go.mod index 766af2a..d38c7a8 100644 --- a/sdks/go/bench/go.mod +++ b/sdks/go/bench/go.mod @@ -1,6 +1,6 @@ module github.com/astraive/loza/sdks/go/bench -go 1.25.0 +go 1.25.13 require ( github.com/astraive/loza/sdks/go v0.0.0 @@ -8,7 +8,7 @@ require ( ) require ( - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/sdks/go/bench/go.sum b/sdks/go/bench/go.sum index 297ba2c..916fb14 100644 --- a/sdks/go/bench/go.sum +++ b/sdks/go/bench/go.sum @@ -51,7 +51,7 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdks/go/docs/sdk-parity-manifest.json b/sdks/go/docs/sdk-parity-manifest.json index b0da7b2..3cfee69 100644 --- a/sdks/go/docs/sdk-parity-manifest.json +++ b/sdks/go/docs/sdk-parity-manifest.json @@ -1,8 +1,8 @@ { - "version": "0.2.6", + "version": "0.3.0", "package": "loza", "scope": "lightweight-sdk", - "stability": "product-parity-v0.2.6", + "stability": "product-parity-v0.3.0", "policy": "full-product-method-family", "sdks": ["go", "python", "rust", "javascript"], "lifecycle": [ diff --git a/sdks/go/examples/httpbatch-to-collector/go.mod b/sdks/go/examples/httpbatch-to-collector/go.mod index 47b1add..013a17f 100644 --- a/sdks/go/examples/httpbatch-to-collector/go.mod +++ b/sdks/go/examples/httpbatch-to-collector/go.mod @@ -1,13 +1,25 @@ module github.com/astraive/loza/sdks/go/examples/httpbatch-to-collector -go 1.25.0 +go 1.25.13 require github.com/astraive/loza/sdks/go v0.0.0 require ( + github.com/astraive/loza/spec v0.2.6 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.42.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) replace github.com/astraive/loza/sdks/go => ../.. + +replace github.com/astraive/loza/spec => ../../../../spec diff --git a/sdks/go/examples/httpbatch-to-collector/go.sum b/sdks/go/examples/httpbatch-to-collector/go.sum index 8caad09..1bfddf7 100644 --- a/sdks/go/examples/httpbatch-to-collector/go.sum +++ b/sdks/go/examples/httpbatch-to-collector/go.sum @@ -1,3 +1,5 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -8,8 +10,28 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -20,5 +42,16 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdks/go/examples/httpbatch-to-collector/main.go b/sdks/go/examples/httpbatch-to-collector/main.go index edd1f6b..4405bdd 100644 --- a/sdks/go/examples/httpbatch-to-collector/main.go +++ b/sdks/go/examples/httpbatch-to-collector/main.go @@ -6,7 +6,7 @@ import ( "time" "github.com/astraive/loza/sdks/go" - "github.com/astraive/loza/sdks/go/sinks/httpbatch" + "github.com/astraive/loza/sdks/go/src/sinks/httpbatch" ) func main() { diff --git a/sdks/go/go.mod b/sdks/go/go.mod index 9ff2181..3409069 100644 --- a/sdks/go/go.mod +++ b/sdks/go/go.mod @@ -1,6 +1,6 @@ module github.com/astraive/loza/sdks/go -go 1.25.0 +go 1.25.13 require ( github.com/astraive/loza/spec v0.2.6 diff --git a/sdks/go/loza-go.yaml b/sdks/go/loza-go.yaml index 5c26966..78c6036 100644 --- a/sdks/go/loza-go.yaml +++ b/sdks/go/loza-go.yaml @@ -1,7 +1,7 @@ name: loza-go kind: sdk language: go -version: 0.2.6 +version: 0.3.0 description: Go SDK for LOZA license: MIT repository: https://github.com/Astraive/loza diff --git a/sdks/go/src/core/collector_client.go b/sdks/go/src/core/collector_client.go index f32c328..d8bf96f 100644 --- a/sdks/go/src/core/collector_client.go +++ b/sdks/go/src/core/collector_client.go @@ -15,38 +15,67 @@ const maxCollectorClientResponseBytes = 10 << 20 // CollectorClient communicates with the LOZA collector REST API. type CollectorClient struct { - endpoint string - apiKey string - client *http.Client + endpoint string + collectorName string + apiKey string + basicUsername string + basicPassword string + initErr error + client *http.Client } // CollectorClientConfig configures the collector client. type CollectorClientConfig struct { - Endpoint string - APIKey string - Client *http.Client + Endpoint string + CollectorName string + APIKey string + BasicUsername string + BasicPassword string + Insecure bool + Client *http.Client } // NewCollectorClient creates a new collector client. func NewCollectorClient(cfg CollectorClientConfig) *CollectorClient { + endpoint := safeCollectorEndpoint(cfg.Endpoint) + initErr := validateCollectorCredentials( + cfg.Endpoint, + cfg.BasicUsername, + cfg.BasicPassword, + cfg.Insecure, + ) if cfg.Client == nil { cfg.Client = &http.Client{} } return &CollectorClient{ - endpoint: strings.TrimRight(cfg.Endpoint, "/"), - apiKey: cfg.APIKey, - client: cfg.Client, + endpoint: strings.TrimRight(endpoint, "/"), + collectorName: cfg.CollectorName, + apiKey: cfg.APIKey, + basicUsername: cfg.BasicUsername, + basicPassword: cfg.BasicPassword, + initErr: initErr, + client: cfg.Client, } } +func (c *CollectorClient) collectorPath(route string) string { + if c.collectorName == "" { + return route + } + return "/collectors/" + url.PathEscape(c.collectorName) + route +} + func (c *CollectorClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) { - url := c.endpoint + path + if c.initErr != nil { + return nil, c.initErr + } + endpoint := c.endpoint + path var req *http.Request var err error if body != nil { - req, err = http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body)) + req, err = http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) } else { - req, err = http.NewRequestWithContext(ctx, method, url, nil) + req, err = http.NewRequestWithContext(ctx, method, endpoint, nil) } if err != nil { return nil, fmt.Errorf("collector: request: %w", err) @@ -54,6 +83,8 @@ func (c *CollectorClient) do(ctx context.Context, method, path string, body []by req.Header.Set("Content-Type", "application/json") if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) + } else if c.basicUsername != "" { + req.Header.Set("Authorization", basicAuthorization(c.basicUsername, c.basicPassword)) } resp, err := c.client.Do(req) if err != nil { @@ -81,7 +112,7 @@ func (c *CollectorClient) Ingest(ctx context.Context, events []json.RawMessage) if err != nil { return nil, fmt.Errorf("collector: marshal: %w", err) } - return c.do(ctx, http.MethodPost, "/ingest", body) + return c.do(ctx, http.MethodPost, c.collectorPath("/events"), body) } // Query queries events from the collector. @@ -91,12 +122,12 @@ func (c *CollectorClient) Query(ctx context.Context, query json.RawMessage) ([]b // Tail tails events from the collector (server-sent events). func (c *CollectorClient) Tail(ctx context.Context, filter json.RawMessage) ([]byte, error) { - return c.do(ctx, http.MethodPost, "/tail", filter) + return c.do(ctx, http.MethodPost, c.collectorPath("/tail"), filter) } // Delete deletes events from the collector. func (c *CollectorClient) Delete(ctx context.Context, filter json.RawMessage) ([]byte, error) { - return c.do(ctx, http.MethodDelete, "/events", filter) + return c.do(ctx, http.MethodDelete, c.collectorPath("/events"), filter) } // Replay replays events through the collector. diff --git a/sdks/go/src/core/config.go b/sdks/go/src/core/config.go index 891a26f..a54cf97 100644 --- a/sdks/go/src/core/config.go +++ b/sdks/go/src/core/config.go @@ -2,7 +2,9 @@ package core import ( "context" + "encoding/base64" "fmt" + "net/url" "os" "strconv" "strings" @@ -139,12 +141,14 @@ type Config struct { TenantID string // Multi-tenant identifier // ── Authentication ─────────────────────────────────────────────────────── - APIKey string // Ingest API key (e.g., "lx_sec_live_k_xxx_yyyy") - Insecure bool // Allow plain HTTP (local dev only). Default: false. + APIKey string // Ingest API key (e.g., "lx_sec_live_k_xxx_yyyy") + DSNUsername string // Private Basic username or public bearer capability from a DSN. + DSNPassword string // Basic password; intentionally empty for lx_pub_ DSNs. + Insecure bool // Allow plain HTTP (local dev only). Default: false. // ── Collector configuration ─────────────────────────────────────────────── - CollectorURL string // URL of the LOZA collector (required) - + CollectorURL string // Credential-free base URL of the LOZA collector (required) + CollectorName string // Collector slug derived from a loza:// DSN path // ── Batching configuration ──────────────────────────────────────────────── BatchSize int // Number of events per batch (default: 100) FlushInterval time.Duration // Time between automatic flushes (default: 5s) @@ -189,8 +193,8 @@ type Config struct { PanicRecovery bool // OTelBridge enables OpenTelemetry trace context extraction from context. // When false (default), TraceFromOTel is skipped to avoid the ~50-100ns context.Value lookup. - OTelBridge bool - Security SecurityConfig + OTelBridge bool + Security SecurityConfig // Strict enables stronger runtime validation for event shape and attrs. Strict bool // ValidateEncoded controls post-encode spec contract validation in strict mode. @@ -202,8 +206,7 @@ type Config struct { IDGen IDGenerator // ── Clock ───────────────────────────────────────────────────────────────── - Clock Clock - + Clock Clock codeSetCompression bool codeSetAsync bool codeSetBackpressure bool @@ -212,6 +215,7 @@ type Config struct { codeSetRedactByDefault bool codeSetAllowPII bool codeSetDropOversized bool + codeSetInsecure bool } // WithService returns a copy of cfg with Service set. @@ -364,9 +368,17 @@ func (c Config) WithAPIKey(apiKey string) Config { return c } +// WithBasicAuth sets the Collector Basic-auth credentials. +func (c Config) WithBasicAuth(username, password string) Config { + c.DSNUsername = username + c.DSNPassword = password + return c +} + // WithInsecure allows plain HTTP connections (for local dev only). func (c Config) WithInsecure(insecure bool) Config { c.Insecure = insecure + c.codeSetInsecure = true return c } @@ -559,6 +571,42 @@ func (c Config) Validate() error { Problem: fmt.Sprintf("must be >= 0 (got %d)", c.Security.MaxAttrCount), } } + if endpointURL, err := url.Parse(strings.TrimSpace(c.CollectorURL)); err == nil && + endpointURL.User != nil { + return &ConfigValidationError{ + Field: "CollectorURL", + Problem: "collector credentials must be supplied through DSN or Basic-auth fields, not embedded in the endpoint URL", + } + } + if c.DSNUsername == "" && c.DSNPassword != "" { + return &ConfigValidationError{ + Field: "DSNUsername/DSNPassword", + Problem: "basic auth password requires a username", + } + } + if c.DSNUsername != "" && c.DSNPassword == "" && !dsn.IsPublicCredentialUsername(c.DSNUsername) { + return &ConfigValidationError{ + Field: "DSNUsername/DSNPassword", + Problem: "basic auth username requires a password unless it is an lx_pub_ capability", + } + } + if c.DSNUsername != "" { + if strings.ContainsAny(c.DSNUsername, ":\t\r\n ") { + return &ConfigValidationError{ + Field: "DSNUsername", + Problem: "basic auth username must not contain ':' or whitespace", + } + } + if endpointURL, err := url.Parse(strings.TrimSpace(c.CollectorURL)); err == nil && + strings.EqualFold(endpointURL.Scheme, "http") && + (!c.Insecure || !isLocalCollectorHost(endpointURL.Hostname())) { + return &ConfigValidationError{ + Field: "CollectorURL", + Problem: "credentialed collector endpoints require TLS (set Insecure only for local development)", + } + } + } + if !c.Strict { return nil } @@ -591,6 +639,65 @@ func (c Config) Validate() error { return nil } +// safeCollectorEndpoint strips URL userinfo before an endpoint is retained or +// passed to net/http. DSN credentials are carried separately in Config. +func safeCollectorEndpoint(raw string) string { + endpoint := strings.TrimSpace(raw) + parsed, err := url.Parse(endpoint) + if err != nil { + return endpoint + } + parsed.User = nil + return parsed.String() +} + +func basicAuthorization(username, password string) string { + token := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + return "Basic " + token +} + +func collectorRouteURL(baseURL, collectorName, route string) string { + baseURL = strings.TrimRight(baseURL, "/") + if collectorName == "" { + return baseURL + route + } + return baseURL + "/collectors/" + url.PathEscape(collectorName) + route +} + +func isLocalCollectorHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func validateCollectorCredentials(endpoint, username, password string, insecure bool) error { + if endpointURL, err := url.Parse(strings.TrimSpace(endpoint)); err == nil && + endpointURL.User != nil { + return fmt.Errorf("loza: collector credentials must not be embedded in the endpoint URL") + } + if username == "" && password != "" { + return fmt.Errorf("loza: basic auth password requires a username") + } + if username != "" && password == "" && !dsn.IsPublicCredentialUsername(username) { + return fmt.Errorf("loza: basic auth username requires a password unless it is an lx_pub_ capability") + } + if username == "" { + return nil + } + if strings.ContainsAny(username, ":\t\r\n ") { + return fmt.Errorf("loza: basic auth username must not contain ':' or whitespace") + } + if endpointURL, err := url.Parse(strings.TrimSpace(endpoint)); err == nil && + strings.EqualFold(endpointURL.Scheme, "http") && + (!insecure || !isLocalCollectorHost(endpointURL.Hostname())) { + return fmt.Errorf("loza: credentialed collector endpoints require TLS (set Insecure only for local development)") + } + return nil +} + // ── Preset configs ──────────────────────────────────────────────────────────── // Dev returns a config suitable for local development: @@ -760,16 +867,22 @@ func envOr(key, def string) string { func LoadFromEnv(base Config) Config { cfg := base - // Load DSN first (sets CollectorURL, Environment, Service, Insecure). - // Individual env vars below can override DSN-derived values. + // Load DSN first (sets CollectorURL, Environment, Service, Insecure, and + // credential fields when userinfo is present). Individual env vars below + // can override DSN-derived values. if rawDSN := os.Getenv("LOZA_DSN"); rawDSN != "" { - if d, err := dsn.Parse(rawDSN); err == nil { + if d, username, password, err := parseSDKDSN(rawDSN); err == nil { cfg.CollectorURL = d.BaseURL + cfg.CollectorName = d.CollectorName cfg.Environment = d.Env if d.Service != "" { cfg.Service = d.Service } cfg.Insecure = !d.TLS + if username != "" { + cfg.DSNUsername = username + cfg.DSNPassword = password + } } // If DSN parsing fails, fall through to individual env vars. } @@ -845,6 +958,45 @@ func LoadFromEnv(base Config) Config { return cfg } +// parseSDKDSN keeps the SDK compatible with released spec modules that parse +// only credential-free endpoint strings while preserving the shared parser's +// endpoint semantics and the credentialed DSN contract. +func parseSDKDSN(raw string) (*dsn.LozaDSN, string, string, error) { + parsed, err := dsn.Parse(raw) + if err == nil { + username, password := credentialedDSNUserinfo(raw) + return parsed, username, password, nil + } + + parsedURL, urlErr := url.Parse(raw) + if urlErr != nil || parsedURL.User == nil { + return nil, "", "", err + } + username, password := parsedURL.User.Username(), "" + var hasPassword bool + password, hasPassword = parsedURL.User.Password() + if username == "" || !hasPassword || (password == "" && !dsn.IsPublicCredentialUsername(username)) { + return nil, "", "", err + } + parsedURL.User = nil + endpoint, endpointErr := dsn.Parse(parsedURL.String()) + if endpointErr != nil { + return nil, "", "", err + } + return endpoint, username, password, nil +} + +func credentialedDSNUserinfo(raw string) (string, string) { + parsed, err := url.Parse(raw) + if err != nil || parsed.User == nil { + return "", "" + } + password, ok := parsed.User.Password() + if !ok { + return "", "" + } + return parsed.User.Username(), password +} // validateSDKConfig validates the SDK configuration per Requirement 32.5 and 32.6. // Returns an error if required fields are missing or values are invalid. @@ -928,8 +1080,9 @@ func NewClient(cfg Config) (*Logger, error) { // Step 3: Apply code-level config (highest precedence) merged = mergeCodeConfig(merged, cfg) + merged.CollectorURL = safeCollectorEndpoint(merged.CollectorURL) if strings.TrimSpace(merged.CollectorURL) == "" && strings.TrimSpace(merged.CollectorEndpoint) != "" { - merged.CollectorURL = strings.TrimSpace(merged.CollectorEndpoint) + merged.CollectorURL = safeCollectorEndpoint(merged.CollectorEndpoint) } // Step 4: Validate the final config @@ -938,12 +1091,14 @@ func NewClient(cfg Config) (*Logger, error) { } // If no explicit sink was configured, route events to the collector endpoint - // using HTTPBatchSink (NDJSON batching with periodic flush). if shouldInstallDefaultCollectorSink(merged) { - // Build headers from config (auth + service identity) + // Build headers from config (auth + service identity). Bearer API-key + // authentication takes precedence over DSN Basic credentials. headers := make(map[string]string) if merged.APIKey != "" { headers["Authorization"] = "Bearer " + merged.APIKey + } else if merged.DSNUsername != "" { + headers["Authorization"] = basicAuthorization(merged.DSNUsername, merged.DSNPassword) } if merged.Service != "" { headers["X-Loza-Service"] = merged.Service @@ -953,11 +1108,12 @@ func NewClient(cfg Config) (*Logger, error) { } batchSink, err := HTTPBatchSink(HTTPBatchSinkConfig{ - Endpoint: strings.TrimRight(merged.CollectorURL, "/") + "/events", + Endpoint: collectorRouteURL(merged.CollectorURL, merged.CollectorName, "/events"), Headers: headers, BatchSize: merged.BatchSize, FlushInterval: merged.Async.FlushInterval, Gzip: merged.EnableCompression, + Insecure: merged.Insecure, }) if err != nil { return nil, fmt.Errorf("loza: initialize httpbatch sink: %w", err) @@ -992,6 +1148,16 @@ func mergeCodeConfig(base, code Config) Config { base.CollectorEndpoint = code.CollectorEndpoint base.CollectorURL = code.CollectorEndpoint } + if code.APIKey != "" { + base.APIKey = code.APIKey + } + if code.DSNUsername != "" { + base.DSNUsername = code.DSNUsername + base.DSNPassword = code.DSNPassword + } + if code.codeSetInsecure || code.Insecure { + base.Insecure = code.Insecure + } if code.Service != "" { base.Service = code.Service } @@ -1004,6 +1170,9 @@ func mergeCodeConfig(base, code Config) Config { if code.TenantID != "" { base.TenantID = code.TenantID } + if code.CollectorName != "" { + base.CollectorName = code.CollectorName + } if code.BatchSize != 0 { base.BatchSize = code.BatchSize } diff --git a/sdks/go/src/core/config_dsn_test.go b/sdks/go/src/core/config_dsn_test.go index 4f63ffb..10d06bd 100644 --- a/sdks/go/src/core/config_dsn_test.go +++ b/sdks/go/src/core/config_dsn_test.go @@ -2,6 +2,7 @@ package core import ( "os" + "strings" "testing" ) @@ -13,29 +14,33 @@ func TestWithDSN(t *testing.T) { wantEnv string wantService string wantInsecure bool + wantCollector string wantParseFail bool }{ { - name: "localhost dev", - dsn: "loza://localhost:9308/demo?env=dev&tls=false", - wantURL: "http://localhost:9308", - wantEnv: "dev", - wantInsecure: true, + name: "localhost dev", + dsn: "loza://localhost:9308/demo?env=dev&tls=false", + wantURL: "http://localhost:9308", + wantEnv: "dev", + wantInsecure: true, + wantCollector: "demo", }, { - name: "prod default tls", - dsn: "loza://collector.example.com/demo?env=prod", - wantURL: "https://collector.example.com:443", - wantEnv: "prod", - wantInsecure: false, + name: "prod default tls", + dsn: "loza://collector.example.com/demo?env=prod", + wantURL: "https://collector.example.com:443", + wantEnv: "prod", + wantInsecure: false, + wantCollector: "demo", }, { - name: "with service", - dsn: "loza://collector.example.com/demo?env=staging&service=auth", - wantURL: "https://collector.example.com:443", - wantEnv: "staging", - wantService: "auth", - wantInsecure: false, + name: "with service", + dsn: "loza://collector.example.com/demo?env=staging&service=auth", + wantURL: "https://collector.example.com:443", + wantEnv: "staging", + wantService: "auth", + wantInsecure: false, + wantCollector: "demo", }, { name: "invalid DSN", @@ -72,6 +77,9 @@ func TestWithDSN(t *testing.T) { if cfg.Insecure != tt.wantInsecure { t.Errorf("Insecure = %v, want %v", cfg.Insecure, tt.wantInsecure) } + if cfg.CollectorName != tt.wantCollector { + t.Errorf("CollectorName = %q, want %q", cfg.CollectorName, tt.wantCollector) + } }) } } @@ -189,3 +197,56 @@ func TestLoadFromEnv_DSN(t *testing.T) { } }) } + +func TestCredentialedDSNMappingAndPrecedence(t *testing.T) { + t.Setenv("LOZA_DSN", "loza://dsn-user:dsn%2Fsecret@collector.example.com/project?env=prod") + t.Setenv("LOZA_API_KEY", "") + + cfg := LoadFromEnv(Config{}) + if cfg.DSNUsername != "dsn-user" || cfg.DSNPassword != "dsn/secret" { + t.Fatalf("DSN credentials = %q/%q, want decoded userinfo", cfg.DSNUsername, cfg.DSNPassword) + } + if strings.Contains(cfg.CollectorURL, "dsn-user") || strings.Contains(cfg.CollectorURL, "secret") { + t.Fatalf("CollectorURL contains DSN credentials: %q", cfg.CollectorURL) + } + + code := ApplyConfig(cfg, WithDSN("loza://code-user:code-secret@collector.example.com/project?env=prod"), WithAPIKey("api-key")) + if code.DSNUsername != "code-user" || code.DSNPassword != "code-secret" { + t.Fatalf("code DSN credentials = %q/%q", code.DSNUsername, code.DSNPassword) + } + if code.APIKey != "api-key" { + t.Fatalf("APIKey = %q, want explicit code API key", code.APIKey) + } +} + +func TestPublicDSNUsesScopedEndpointAndEmptyBasicPassword(t *testing.T) { + const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN" + cfg := ApplyConfig( + Config{}, + WithDSN("loza://"+capability+":@collector.example.com/public-collector?env=prod"), + WithAPIKey("api-key"), + ) + + if cfg.CollectorName != "public-collector" { + t.Fatalf("CollectorName = %q, want public-collector", cfg.CollectorName) + } + if cfg.DSNUsername != capability || cfg.DSNPassword != "" { + t.Fatalf("DSN credentials = %q/%q, want public capability and empty password", cfg.DSNUsername, cfg.DSNPassword) + } + if strings.Contains(cfg.CollectorURL, capability) { + t.Fatalf("CollectorURL leaked public capability: %q", cfg.CollectorURL) + } + if cfg.APIKey != "api-key" { + t.Fatalf("API key precedence was not retained") + } +} + +func TestCredentialedDSNRejectsRemotePlaintext(t *testing.T) { + cfg := ApplyConfig( + Config{}, + WithDSN("loza://dsn-user:dsn-secret@collector.example.com/project?tls=false"), + ) + if err := cfg.Validate(); err == nil { + t.Fatal("expected remote plaintext Basic-auth DSN to be rejected") + } +} diff --git a/sdks/go/src/core/config_options.go b/sdks/go/src/core/config_options.go index 6fc253a..73d0be1 100644 --- a/sdks/go/src/core/config_options.go +++ b/sdks/go/src/core/config_options.go @@ -2,8 +2,6 @@ package core import ( "time" - - "github.com/astraive/loza/spec/dsn" ) // ConfigOption mutates and returns a Config. @@ -158,6 +156,14 @@ func WithCollectorURL(url string) ConfigOption { } } +// WithCollectorName applies the canonical collector slug used for scoped routes. +func WithCollectorName(name string) ConfigOption { + return func(cfg Config) Config { + cfg.CollectorName = name + return cfg + } +} + // WithTenantID applies the tenant ID. func WithTenantID(tenantID string) ConfigOption { return func(cfg Config) Config { @@ -262,6 +268,25 @@ func WithAPIKey(apiKey string) ConfigOption { } } +// WithBasicAuth sets Collector Basic-auth credentials. API-key authentication +// still takes precedence when both are configured. +func WithBasicAuth(username, password string) ConfigOption { + return func(cfg Config) Config { + cfg.DSNUsername = username + cfg.DSNPassword = password + return cfg + } +} + +// WithInsecure allows plain HTTP connections for explicitly local development. +func WithInsecure(insecure bool) ConfigOption { + return func(cfg Config) Config { + cfg.Insecure = insecure + cfg.codeSetInsecure = true + return cfg + } +} + // WithOtelBridge enables or disables OpenTelemetry bridge integration. func WithOtelBridge(enabled bool) ConfigOption { return func(cfg Config) Config { @@ -291,8 +316,9 @@ func WithLogger(l *Logger) ConfigOption { } // WithDSN parses a loza:// connection URI and applies the resolved values -// to the config. It sets CollectorURL, Environment, Service (if present in -// the DSN), and Insecure (derived from TLS setting). +// to the config. It retains the credential-free collector base URL and records +// the required collector slug so the default transport targets canonical +// /collectors/{collector}/events routes. // // Individual config options or env vars applied after WithDSN will override // the DSN-derived values. @@ -300,22 +326,28 @@ func WithLogger(l *Logger) ConfigOption { // Example: // // config.NewClient(config.Production(), -// config.WithDSN("loza://localhost:9308/demo?env=dev&tls=false"), +// config.WithDSN("loza://key-id:key-secret@collector.example/demo?env=prod"), // ) func WithDSN(raw string) ConfigOption { return func(cfg Config) Config { - d, err := dsn.Parse(raw) + d, username, password, err := parseSDKDSN(raw) if err != nil { // Store the parse error; it will surface during NewClient validation. cfg.CollectorURL = "" // signal invalid state return cfg } cfg.CollectorURL = d.BaseURL + cfg.CollectorName = d.CollectorName cfg.Environment = d.Env if d.Service != "" { cfg.Service = d.Service } + if username != "" { + cfg.DSNUsername = username + cfg.DSNPassword = password + } cfg.Insecure = !d.TLS + cfg.codeSetInsecure = true return cfg } } diff --git a/sdks/go/src/core/standard_sinks.go b/sdks/go/src/core/standard_sinks.go index cc2dba4..20c7fc5 100644 --- a/sdks/go/src/core/standard_sinks.go +++ b/sdks/go/src/core/standard_sinks.go @@ -18,6 +18,23 @@ import ( speccontract "github.com/astraive/loza/spec/generated/go/contract" ) +func cloneHeaders(headers map[string]string) map[string]string { + cloned := make(map[string]string, len(headers)) + for key, value := range headers { + cloned[key] = value + } + return cloned +} + +func hasNonEmptyHeader(headers map[string]string, name string) bool { + for key, value := range headers { + if strings.EqualFold(key, name) && value != "" { + return true + } + } + return false +} + // ── stdout / stderr sinks ───────────────────────────────────────────────────── type writerSink struct { @@ -261,6 +278,9 @@ func NoopSink() Sink { return noopSink{} } type CollectorSinkConfig struct { Endpoint string Headers map[string]string + BasicUsername string + BasicPassword string + Insecure bool Client *http.Client Transport *HTTPTransport Metrics *MetricsCollector @@ -282,6 +302,25 @@ func CollectorSink(cfg CollectorSinkConfig) (Sink, error) { if strings.TrimSpace(cfg.Endpoint) == "" { return nil, fmt.Errorf("loza: collector endpoint is required") } + if err := validateCollectorCredentials( + cfg.Endpoint, + cfg.BasicUsername, + cfg.BasicPassword, + cfg.Insecure, + ); err != nil { + return nil, err + } + cfg.Endpoint = safeCollectorEndpoint(cfg.Endpoint) + if cfg.BasicUsername != "" { + if cfg.Headers == nil { + cfg.Headers = make(map[string]string) + } else { + cfg.Headers = cloneHeaders(cfg.Headers) + } + if !hasNonEmptyHeader(cfg.Headers, "Authorization") { + cfg.Headers["Authorization"] = basicAuthorization(cfg.BasicUsername, cfg.BasicPassword) + } + } if cfg.Transport == nil { cfg.Transport = NewHTTPTransport(HTTPTransportConfig{ MaxRetries: cfg.MaxRetries, @@ -488,6 +527,9 @@ func gzipBody(body []byte) ([]byte, error) { type HTTPBatchSinkConfig struct { Endpoint string Headers map[string]string + BasicUsername string + BasicPassword string + Insecure bool BatchSize int FlushInterval time.Duration Gzip bool @@ -503,12 +545,29 @@ type httpBatchSink struct { closeOnce sync.Once } -// HTTPBatchSink creates a sink that batches events as NDJSON and flushes -// to the endpoint when BatchSize is reached or FlushInterval elapses. func HTTPBatchSink(cfg HTTPBatchSinkConfig) (Sink, error) { if strings.TrimSpace(cfg.Endpoint) == "" { return nil, fmt.Errorf("loza: httpbatch endpoint is required") } + if err := validateCollectorCredentials( + cfg.Endpoint, + cfg.BasicUsername, + cfg.BasicPassword, + cfg.Insecure, + ); err != nil { + return nil, err + } + cfg.Endpoint = safeCollectorEndpoint(cfg.Endpoint) + if cfg.BasicUsername != "" { + if cfg.Headers == nil { + cfg.Headers = make(map[string]string) + } else { + cfg.Headers = cloneHeaders(cfg.Headers) + } + if !hasNonEmptyHeader(cfg.Headers, "Authorization") { + cfg.Headers["Authorization"] = basicAuthorization(cfg.BasicUsername, cfg.BasicPassword) + } + } if cfg.BatchSize <= 0 { cfg.BatchSize = 100 } diff --git a/sdks/go/src/core/standard_sinks_test.go b/sdks/go/src/core/standard_sinks_test.go index 16a2aef..c9a490b 100644 --- a/sdks/go/src/core/standard_sinks_test.go +++ b/sdks/go/src/core/standard_sinks_test.go @@ -204,3 +204,106 @@ func TestCollectorSink_CompressesRequestBodyWhenEnabled(t *testing.T) { t.Fatalf("expected compressed collector envelope, got %s", string(decompressed)) } } + +func TestHTTPBatchSink_BasicAuthAndSafeEndpoint(t *testing.T) { + var gotUser, gotPassword string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPassword, _ = r.BasicAuth() + if strings.Contains(r.URL.String(), "dsn-secret") { + t.Errorf("request URL contains credential material: %q", r.URL.String()) + } + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + endpoint := srv.URL + "/events" + sink, err := HTTPBatchSink(HTTPBatchSinkConfig{ + Endpoint: endpoint, + BasicUsername: "dsn-user", + BasicPassword: "dsn-secret", + Insecure: true, + BatchSize: 1, + }) + if err != nil { + t.Fatalf("HTTPBatchSink() error = %v", err) + } + defer sink.Close(context.Background()) + if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil { + t.Fatalf("WriteEvent() error = %v", err) + } + if gotUser != "dsn-user" || gotPassword != "dsn-secret" { + t.Fatalf("BasicAuth = %q/%q, want DSN credentials", gotUser, gotPassword) + } +} + +func TestHTTPBatchSink_PublicBasicAuthUsesEmptyPassword(t *testing.T) { + const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN" + var gotUser, gotPassword, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPassword, _ = r.BasicAuth() + gotPath = r.URL.Path + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + sink, err := HTTPBatchSink(HTTPBatchSinkConfig{ + Endpoint: srv.URL + "/collectors/public-collector/events", + BasicUsername: capability, + BasicPassword: "", + Insecure: true, + BatchSize: 1, + }) + if err != nil { + t.Fatalf("HTTPBatchSink() error = %v", err) + } + defer sink.Close(context.Background()) + if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil { + t.Fatalf("WriteEvent() error = %v", err) + } + if gotUser != capability || gotPassword != "" { + t.Fatalf("BasicAuth = %q/%q, want public capability with empty password", gotUser, gotPassword) + } + if gotPath != "/collectors/public-collector/events" { + t.Fatalf("request path = %q, want collector-scoped events route", gotPath) + } +} + +func TestHTTPBatchSink_PreservesCaseInsensitiveAuthorizationHeader(t *testing.T) { + var gotAuthorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + sink, err := HTTPBatchSink(HTTPBatchSinkConfig{ + Endpoint: srv.URL + "/events", + Headers: map[string]string{"authorization": "Bearer existing"}, + BasicUsername: "dsn-user", + BasicPassword: "dsn-secret", + Insecure: true, + BatchSize: 1, + }) + if err != nil { + t.Fatalf("HTTPBatchSink() error = %v", err) + } + defer sink.Close(context.Background()) + if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil { + t.Fatalf("WriteEvent() error = %v", err) + } + if gotAuthorization != "Bearer existing" { + t.Fatalf("Authorization = %q, want existing Bearer header", gotAuthorization) + } +} + +func TestCollectorClient_RejectsRemotePlaintextBasicAuth(t *testing.T) { + client := NewCollectorClient(CollectorClientConfig{ + Endpoint: "http://collector.example.com", + BasicUsername: "dsn-user", + BasicPassword: "dsn-secret", + }) + _, err := client.Ingest(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "require TLS") { + t.Fatalf("Ingest() error = %v, want plaintext Basic-auth rejection", err) + } +} diff --git a/sdks/go/src/core/version.go b/sdks/go/src/core/version.go index b637c35..bfa00c5 100644 --- a/sdks/go/src/core/version.go +++ b/sdks/go/src/core/version.go @@ -6,7 +6,7 @@ import ( "sync" ) -const fallbackVersion = "0.2.6" +const fallbackVersion = "0.3.0" var ( sdkVersionOnce sync.Once diff --git a/sdks/go/src/integrations/go.mod b/sdks/go/src/integrations/go.mod index cb86001..ac7c3c0 100644 --- a/sdks/go/src/integrations/go.mod +++ b/sdks/go/src/integrations/go.mod @@ -1,6 +1,6 @@ module github.com/astraive/loza/sdks/go/src/integrations -go 1.25.0 +go 1.25.13 require ( github.com/astraive/loza/sdks/go v0.0.0 @@ -11,7 +11,7 @@ require ( ) require ( - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/sdks/go/src/integrations/go.sum b/sdks/go/src/integrations/go.sum index b3f164f..5681471 100644 --- a/sdks/go/src/integrations/go.sum +++ b/sdks/go/src/integrations/go.sum @@ -64,7 +64,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdks/go/src/middleware/go.mod b/sdks/go/src/middleware/go.mod index e501af0..bd267d1 100644 --- a/sdks/go/src/middleware/go.mod +++ b/sdks/go/src/middleware/go.mod @@ -1,6 +1,6 @@ module github.com/astraive/loza/sdks/go/src/middleware -go 1.25.0 +go 1.25.13 require ( github.com/astraive/loza/sdks/go v0.0.0 @@ -13,7 +13,7 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect diff --git a/sdks/go/tests/conformance/manifest_sync_test.go b/sdks/go/tests/conformance/manifest_sync_test.go index c7d0a25..4571efa 100644 --- a/sdks/go/tests/conformance/manifest_sync_test.go +++ b/sdks/go/tests/conformance/manifest_sync_test.go @@ -25,11 +25,11 @@ func TestParityManifestMatchesStableLifecycleDoc(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatalf("unmarshal parity manifest: %v", err) } - if manifest.Version != "0.2.6" { - t.Fatalf("expected product parity manifest version 0.2.6, got %q", manifest.Version) + if manifest.Version != "0.3.0" { + t.Fatalf("expected product parity manifest version 0.3.0, got %q", manifest.Version) } - if manifest.Stability != "product-parity-v0.2.6" { - t.Fatalf("expected stability product-parity-v0.2.6, got %q", manifest.Stability) + if manifest.Stability != "product-parity-v0.3.0" { + t.Fatalf("expected stability product-parity-v0.3.0, got %q", manifest.Stability) } docPath := filepath.Join("..", "..", "docs", "public-api.md") diff --git a/sdks/go/tests/e2e/go.mod b/sdks/go/tests/e2e/go.mod index 8db0f63..7c11b9b 100644 --- a/sdks/go/tests/e2e/go.mod +++ b/sdks/go/tests/e2e/go.mod @@ -1,11 +1,11 @@ module github.com/astraive/loza/sdks/go/tests/e2e -go 1.25.0 +go 1.25.13 require github.com/astraive/loza/sdks/go v0.0.0 require ( - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/sdks/go/tests/e2e/go.sum b/sdks/go/tests/e2e/go.sum index d5132c5..1bfddf7 100644 --- a/sdks/go/tests/e2e/go.sum +++ b/sdks/go/tests/e2e/go.sum @@ -51,7 +51,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdks/go/tests/integration/go.mod b/sdks/go/tests/integration/go.mod index df6d933..b75b9d4 100644 --- a/sdks/go/tests/integration/go.mod +++ b/sdks/go/tests/integration/go.mod @@ -1,6 +1,6 @@ module github.com/astraive/loza/sdks/go/tests/integration -go 1.25.0 +go 1.25.13 require ( github.com/astraive/loza/sdks/go v0.0.0 @@ -8,7 +8,7 @@ require ( ) require ( - github.com/astraive/loza/spec v0.0.0 // indirect + github.com/astraive/loza/spec v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/sdks/go/tests/integration/go.sum b/sdks/go/tests/integration/go.sum index 297ba2c..916fb14 100644 --- a/sdks/go/tests/integration/go.sum +++ b/sdks/go/tests/integration/go.sum @@ -51,7 +51,7 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdks/js/README.md b/sdks/js/README.md index 8d650a4..a7a74f9 100644 --- a/sdks/js/README.md +++ b/sdks/js/README.md @@ -2,7 +2,7 @@ LOZA wide-event SDK for JavaScript/TypeScript — lightweight bridge connector to loza-collector. -**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance +**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance ## Installation diff --git a/sdks/js/docs/sdk-parity-manifest.json b/sdks/js/docs/sdk-parity-manifest.json index b0da7b2..3cfee69 100644 --- a/sdks/js/docs/sdk-parity-manifest.json +++ b/sdks/js/docs/sdk-parity-manifest.json @@ -1,8 +1,8 @@ { - "version": "0.2.6", + "version": "0.3.0", "package": "loza", "scope": "lightweight-sdk", - "stability": "product-parity-v0.2.6", + "stability": "product-parity-v0.3.0", "policy": "full-product-method-family", "sdks": ["go", "python", "rust", "javascript"], "lifecycle": [ diff --git a/sdks/js/loza-js.yaml b/sdks/js/loza-js.yaml index 79c5655..4b4e2ea 100644 --- a/sdks/js/loza-js.yaml +++ b/sdks/js/loza-js.yaml @@ -1,11 +1,11 @@ name: loza-js kind: sdk language: javascript -version: 0.2.6 +version: 0.3.0 description: JavaScript and TypeScript SDK for LOZA license: MIT repository: https://github.com/Astraive/loza -module: npm:loza +module: npm:@astraive/loza paths: root: sdks/js @@ -13,5 +13,5 @@ paths: publish: npm: owner: astraive - package: loza + package: "@astraive/loza" directory: sdks/js diff --git a/sdks/js/package-lock.json b/sdks/js/package-lock.json index 79417f1..44e5cc3 100644 --- a/sdks/js/package-lock.json +++ b/sdks/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@astraive/loza", - "version": "0.2.6", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@astraive/loza", - "version": "0.2.6", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/node": "^25.9.1", diff --git a/sdks/js/package.json b/sdks/js/package.json index f9fd5b2..2a7408b 100644 --- a/sdks/js/package.json +++ b/sdks/js/package.json @@ -1,7 +1,7 @@ { "name": "@astraive/loza", "type": "module", - "version": "0.2.6", + "version": "0.3.0", "description": "LOZA wide-event SDK for JavaScript/TypeScript — lightweight bridge connector to loza-collector", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/sdks/js/src/collector/client.ts b/sdks/js/src/collector/client.ts index 9c1a56d..641de8b 100644 --- a/sdks/js/src/collector/client.ts +++ b/sdks/js/src/collector/client.ts @@ -4,13 +4,16 @@ import { gzip as zlibGzip } from 'node:zlib'; import { promisify } from 'node:util'; import { buildIngestEnvelope, parseCollectorResponse } from '../generated/spec-contract.ts'; import type { CollectorResponse } from '../generated/spec-contract.ts'; +import { isPublicDSNUsername } from '../config/dsn.ts'; import { SDK_VERSION } from '../config/version.ts'; - const gzipAsync = promisify(zlibGzip); export interface CollectorClientOptions { url: string; + collectorName?: string; apiKey?: string; + username?: string; + password?: string; authHeader?: string; timeout?: number; enableCompression?: boolean; @@ -26,14 +29,35 @@ export interface VersionInfo { /** Standalone client for loza-collector HTTP API. */ export class CollectorClient { private url: string; + private collectorName: string; private apiKey: string; + private username: string; + private password: string; private authHeader: string; private timeout: number; private enableCompression: boolean; constructor(opts: CollectorClientOptions) { this.url = opts.url.replace(/\/$/, ''); + const endpoint = new URL(this.url); + if (endpoint.username || endpoint.password) { + throw new Error('invalid CollectorClient URL: credentials must not be embedded in the URL'); + } this.apiKey = opts.apiKey || ''; + this.username = opts.username || ''; + this.password = opts.password || ''; + this.collectorName = opts.collectorName || ''; + if (!this.username && this.password) { + throw new Error('invalid CollectorClient options: Basic password requires a username'); + } + if (this.username && !this.password && !isPublicDSNUsername(this.username)) { + throw new Error('invalid CollectorClient options: Basic credentials require a password unless username is an lx_pub_ capability'); + } + if (!this.apiKey && this.username && + endpoint.protocol === 'http:' && + !['localhost', '127.0.0.1', '::1'].includes(endpoint.hostname)) { + throw new Error('invalid CollectorClient options: Basic credentials require HTTPS (HTTP is allowed only for localhost)'); + } this.authHeader = opts.authHeader || 'x-loza-api-key'; this.timeout = opts.timeout || 5000; this.enableCompression = opts.enableCompression ?? true; @@ -85,7 +109,7 @@ export class CollectorClient { : this.apiKey; } - const res = await this.request('POST', '/events', body, headers); + const res = await this.request('POST', this.collectorPath('/events'), body, headers); return parseCollectorResponse(res.body); } @@ -93,7 +117,7 @@ export class CollectorClient { /** Validate an event against the collector schema. */ async validate(event: Record): Promise { - const res = await this.request('POST', '/validate', Buffer.from(JSON.stringify(event), 'utf-8')); + const res = await this.request('POST', this.collectorPath('/validate'), Buffer.from(JSON.stringify(event), 'utf-8')); return JSON.parse(res.body); } @@ -104,20 +128,22 @@ export class CollectorClient { /** Query events from the collector. */ async query(query: Record): Promise { - const res = await this.request('POST', '/query', Buffer.from(JSON.stringify(query), 'utf-8')); + const res = await this.request('POST', this.collectorPath('/query'), Buffer.from(JSON.stringify(query), 'utf-8')); return JSON.parse(res.body); } /** Tail events from the collector (streaming stub). */ - async tail(query?: Record): Promise { - const params = query ? '?' + new URLSearchParams(query as any).toString() : ''; - const res = await this.request('GET', `/tail${params}`); + async tail(query?: Record): Promise { + const params = query + ? `?${new URLSearchParams(Object.entries(query).map(([key, value]) => [key, String(value)])).toString()}` + : ''; + const res = await this.request('GET', `${this.collectorPath('/tail')}${params}`); return JSON.parse(res.body); } /** Delete events from the collector. */ async delete(query: Record): Promise { - const res = await this.request('DELETE', '/events', Buffer.from(JSON.stringify(query), 'utf-8')); + const res = await this.request('DELETE', this.collectorPath('/events'), Buffer.from(JSON.stringify(query), 'utf-8')); return JSON.parse(res.body); } @@ -200,6 +226,12 @@ export class CollectorClient { return JSON.parse(res.body); } + private collectorPath(path: string): string { + return this.collectorName + ? `/collectors/${encodeURIComponent(this.collectorName)}${path}` + : path; + } + private get(path: string): Promise<{ statusCode: number; body: string }> { return this.request('GET', path); } @@ -211,7 +243,11 @@ export class CollectorClient { const mod = isHttps ? https : http; const headers: Record = { ...extraHeaders }; - if (this.apiKey && !headers[this.authHeader]) headers[this.authHeader] = this.apiKey; + if (this.apiKey && !headers[this.authHeader]) headers[this.authHeader] = + this.authHeader.toLowerCase() === 'authorization' ? `Bearer ${this.apiKey}` : this.apiKey; + if (!this.apiKey && this.username && (this.password || isPublicDSNUsername(this.username)) && !headers.Authorization) { + headers.Authorization = `Basic ${Buffer.from(`${this.username}:${this.password}`, 'utf8').toString('base64')}`; + } if (body) headers['Content-Length'] = body.length.toString(); const req = mod.request({ diff --git a/sdks/js/src/config/config-file.ts b/sdks/js/src/config/config-file.ts index 611102a..546d89c 100644 --- a/sdks/js/src/config/config-file.ts +++ b/sdks/js/src/config/config-file.ts @@ -244,6 +244,11 @@ export function mergeFileConfig(base: Config, raw: YamlRecord): Config { try { const dsn = parseDSN(cfg.collectorUrl); cfg.collectorUrl = dsn.baseURL; + cfg.collectorName = dsn.collectorName; + if (dsn.username !== undefined) { + cfg.username = dsn.username; + cfg.password = dsn.password ?? ''; + } // Extract DSN-derived values (only override if not already set from YAML) if (dsn.env && dsn.env !== 'default' && cfg.environment === 'development') { cfg.environment = dsn.env; diff --git a/sdks/js/src/config/config.ts b/sdks/js/src/config/config.ts index aa22ae8..79f2ada 100644 --- a/sdks/js/src/config/config.ts +++ b/sdks/js/src/config/config.ts @@ -8,7 +8,56 @@ import type { Schema } from '../core/schema.ts'; import { DefaultSchema } from '../core/schema.ts'; import type { Level } from '../core/level.ts'; import { loadFileConfig, mergeFileConfig } from './config-file.ts'; -import { parse as parseDSN } from './dsn.ts'; +import { isPublicDSNUsername, parse as parseDSN } from './dsn.ts'; + +function isLocalhost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hasBasicCredentials(cfg: Pick): boolean { + return !cfg.apiKey && !!cfg.username && (!!cfg.password || isPublicDSNUsername(cfg.username)); +} + +export function collectorRouteURL(baseURL: string, collectorName: string, route: string): string { + const base = baseURL.replace(/\/+$/, ''); + return collectorName ? `${base}/collectors/${encodeURIComponent(collectorName)}${route}` : `${base}${route}`; +} + +/** Apply a collector endpoint, resolving loza:// DSNs without retaining userinfo. */ +function applyCollectorURL(cfg: Config, raw: string): void { + if (!raw.startsWith('loza://')) { + cfg.collectorUrl = raw; + return; + } + const dsn = parseDSN(raw); + cfg.collectorUrl = dsn.baseURL; + cfg.collectorName = dsn.collectorName; + if (dsn.username !== undefined) { + cfg.username = dsn.username; + cfg.password = dsn.password ?? ''; + } + if (dsn.env && dsn.env !== 'default') cfg.environment = dsn.env; + if (dsn.service) cfg.service = dsn.service; +} + +/** Reject credentialed non-local plaintext HTTP before any request is made. */ +export function validateConfig(cfg: Config): Config { + if (!cfg.collectorUrl) return cfg; + const endpoint = new URL(cfg.collectorUrl); + if (endpoint.username || endpoint.password) { + throw new Error('invalid Loza config: credentials must not be embedded in the collector URL'); + } + if (!cfg.username && cfg.password) { + throw new Error('invalid Loza config: Basic password requires a username'); + } + if (cfg.username && !cfg.password && !isPublicDSNUsername(cfg.username)) { + throw new Error('invalid Loza config: Basic credentials require a password unless username is an lx_pub_ capability'); + } + if (hasBasicCredentials(cfg) && endpoint.protocol === 'http:' && !isLocalhost(endpoint.hostname)) { + throw new Error('invalid Loza config: Basic credentials require HTTPS (HTTP is allowed only for localhost)'); + } + return cfg; +} /** Async delivery configuration. */ export interface AsyncConfig { @@ -34,11 +83,14 @@ export interface Config { service: string; alias: string; version: string; - environment: string; release: string; + environment: string; namespace: string; collectorUrl: string; + collectorName: string; apiKey: string; + username: string; + password: string; sink: Sink | null; sinks: Sink[]; sampler: Sampler; @@ -91,24 +143,19 @@ export function fromEnv(): Config { // Layer 3: Environment variables if (typeof process !== 'undefined') { - // Parse LOZA_DSN first (sets collectorUrl, environment, service) + // Parse LOZA_DSN first. Resolved URLs never retain userinfo. const dsnRaw = process.env.LOZA_DSN; if (dsnRaw) { try { - const dsn = parseDSN(dsnRaw); - cfg.collectorUrl = dsn.baseURL; - if (dsn.env && dsn.env !== 'default') { - cfg.environment = dsn.env; - } - if (dsn.service) { - cfg.service = dsn.service; - } + applyCollectorURL(cfg, dsnRaw); } catch { // Invalid DSN — fall through to individual env vars } } - // Individual env vars override DSN-derived and file-derived values + // Individual env vars override DSN-derived and file-derived values. + // LOZA_COLLECTOR_URL intentionally overrides only the endpoint; DSN + // environment/service/credentials remain in effect. cfg.service = process.env.LOZA_SERVICE || process.env.SERVICE || cfg.service; cfg.version = process.env.LOZA_VERSION || process.env.VERSION || cfg.version; cfg.environment = process.env.LOZA_ENVIRONMENT || process.env.ENVIRONMENT || cfg.environment; @@ -118,7 +165,7 @@ export function fromEnv(): Config { cfg.apiKey = process.env.LOZA_API_KEY || process.env.API_KEY || cfg.apiKey; cfg.level = process.env.LOZA_LEVEL || process.env.LOG_LEVEL || cfg.level; } - return cfg; + return validateConfig(cfg); } export function defaultConfig(): Config { @@ -130,7 +177,10 @@ export function defaultConfig(): Config { release: '', namespace: '', collectorUrl: '', + collectorName: '', apiKey: (typeof process !== 'undefined' && process.env?.LOZA_API_KEY) || '', + username: '', + password: '', sink: null, sinks: [], sampler: sampleAll(), @@ -201,7 +251,10 @@ export interface ConfigOptions { release?: string; namespace?: string; collectorUrl?: string; + collectorName?: string; apiKey?: string; + username?: string; + password?: string; sink?: Sink; sampler?: Sampler; redactor?: Redactor; @@ -226,11 +279,14 @@ export class ConfigBuilder implements Config { service: string; alias: string; version: string; - environment: string; release: string; + environment: string; namespace: string; collectorUrl: string; + collectorName: string; apiKey: string; + username: string; + password: string; sink: Sink | null; sinks: Sink[]; sampler: Sampler; @@ -255,11 +311,14 @@ export class ConfigBuilder implements Config { this.service = base.service; this.alias = base.alias; this.version = base.version; - this.environment = base.environment; this.release = base.release; + this.environment = base.environment; this.namespace = base.namespace; this.collectorUrl = base.collectorUrl; + this.collectorName = base.collectorName; this.apiKey = base.apiKey; + this.username = base.username; + this.password = base.password; this.sink = base.sink; this.sinks = base.sinks; this.sampler = base.sampler; @@ -285,8 +344,17 @@ export class ConfigBuilder implements Config { withAlias(alias: string): this { this.alias = alias; return this; } withVersion(version: string): this { this.version = version; return this; } withEnvironment(environment: string): this { this.environment = environment; return this; } - withCollectorUrl(url: string): this { this.collectorUrl = url; return this; } + withCollectorUrl(url: string): this { + Object.assign(this, withOptions(this, { collectorUrl: url })); + return this; + } + withCollectorName(collectorName: string): this { this.collectorName = collectorName; return this; } withApiKey(apiKey: string): this { this.apiKey = apiKey.trim(); return this; } + withBasicAuth(username: string, password: string): this { + this.username = username; + this.password = password; + return this; + } withSink(sink: Sink): this { this.sink = sink; return this; } withSinks(...sinks: Sink[]): this { this.sinks = sinks; return this; } withSampler(sampler: Sampler): this { this.sampler = sampler; return this; } @@ -294,9 +362,9 @@ export class ConfigBuilder implements Config { withSchema(schema: Schema): this { this.schema = schema; return this; } withLevel(level: string): this { this.level = level; return this; } withStrict(strict: boolean): this { this.strict = strict; return this; } - withAsync(enabled: boolean): this { this.async.enabled = enabled; return this; } - withCollectorEndpoint(url: string): this { this.collectorUrl = url; return this; } withDuplicatePolicy(policy: string): this { this.duplicatePolicy = policy; return this; } + withAsync(enabled: boolean): this { this.async.enabled = enabled; return this; } + withCollectorEndpoint(url: string): this { return this.withCollectorUrl(url); } withStatsHandler(_handler: unknown): this { return this; } withDeploymentID(_deploymentId: string): this { return this; } withIncludeHost(includeHost: boolean): this { this.includeHost = includeHost; return this; } @@ -315,7 +383,7 @@ export class ConfigBuilder implements Config { withQueueSize(size: number): this { this.async.queueSize = size; return this; } withLogger(logger: Logger): this { this.logger = logger; return this; } disabled(): Config { return { ...this, sink: null, sampler: sampleNone() }; } - build(): Config { return { ...this }; } + build(): Config { return validateConfig({ ...this }); } } /** Create a config from a base config and options. */ @@ -325,8 +393,13 @@ export function withOptions(base: Config, opts: ConfigOptions): Config { if (opts.alias !== undefined) cfg.alias = opts.alias; if (opts.version !== undefined) cfg.version = opts.version; if (opts.environment !== undefined) cfg.environment = opts.environment; - if (opts.collectorUrl !== undefined) cfg.collectorUrl = opts.collectorUrl; - if (opts.sink !== undefined) cfg.sink = opts.sink; + if (opts.release !== undefined) cfg.release = opts.release; + if (opts.namespace !== undefined) cfg.namespace = opts.namespace; + if (opts.collectorUrl !== undefined) applyCollectorURL(cfg, opts.collectorUrl); + if (opts.collectorName !== undefined) cfg.collectorName = opts.collectorName; + if (opts.apiKey !== undefined) cfg.apiKey = opts.apiKey.trim(); + if (opts.username !== undefined) cfg.username = opts.username; + if (opts.password !== undefined) cfg.password = opts.password; if (opts.sampler !== undefined) cfg.sampler = opts.sampler; if (opts.redactor !== undefined) cfg.redactor = opts.redactor; if (opts.schema !== undefined) cfg.schema = opts.schema; @@ -340,7 +413,7 @@ export function withOptions(base: Config, opts: ConfigOptions): Config { if (opts.enableCompression !== undefined) cfg.enableCompression = opts.enableCompression; if (opts.duplicatePolicy !== undefined) cfg.duplicatePolicy = opts.duplicatePolicy; if (opts.includeHost !== undefined) cfg.includeHost = opts.includeHost; - return cfg; + return validateConfig(cfg); } export type ConfigOption = (cfg: Config) => Config; @@ -357,6 +430,10 @@ export function WithEventSchema(schema: Schema): ConfigOption { return WithSchem export function WithAsync(enabled: boolean): ConfigOption { return cfg => withOptions(cfg, { async: enabled }); } export function WithCollectorEndpoint(collectorUrl: string): ConfigOption { return cfg => withOptions(cfg, { collectorUrl }); } export function WithDuplicatePolicy(duplicatePolicy: string): ConfigOption { return cfg => withOptions(cfg, { duplicatePolicy }); } + +export function WithBasicAuth(username: string, password: string): ConfigOption { + return cfg => withOptions(cfg, { username, password }); +} export function WithStatsHandler(statsHandler: unknown): ConfigOption { return cfg => withOptions(cfg, { statsHandler }); } export function WithDeploymentID(deploymentId: string): ConfigOption { return cfg => withOptions(cfg, { deploymentId }); } export function WithIncludeHost(includeHost: boolean): ConfigOption { return cfg => withOptions(cfg, { includeHost }); } diff --git a/sdks/js/src/config/dsn.ts b/sdks/js/src/config/dsn.ts index d7e74a6..a8df801 100644 --- a/sdks/js/src/config/dsn.ts +++ b/sdks/js/src/config/dsn.ts @@ -5,38 +5,55 @@ * It resolves to HTTP/HTTPS/OTLP/gRPC/WebSocket endpoints — it is NOT a wire protocol. * * Format: - * loza://[host][:port]/[project]?env=&service=&tls=&transport= - */ + * loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport= + * + * Private credentials use username:password. Public lx_pub_... credentials use + * an explicitly empty password (lx_pub_...:) as a bearer capability. /** Parsed and resolved values from a loza:// connection URI. */ export interface LozaDSN { - scheme: string; // always "loza" - host: string; // hostname (no port) - port: number; // resolved port number - project: string; // path segment (project name) - env: string; // environment name (default: "default") - service: string; // optional service name - tls: boolean; // whether to use HTTPS - transport: string; // "http", "otlp", or "grpc" (default: "http") - baseURL: string; // resolved http(s)://host:port - eventsURL: string; // base + /events - batchURL: string; // base + /events/batch - otlpURL: string; // base + /otlp/logs - tailWSURL: string; // ws(s)://host:port/tail + scheme: string; // always "loza" + username?: string; // decoded private key ID or public bearer capability + password?: string; // decoded private key secret; empty for public capabilities + host: string; // hostname (no port) + port: number; // resolved port number + collectorName: string; // canonical collector slug from the required path + project: string; // compatibility alias for collectorName + env: string; // environment name (default: "default") + service: string; // optional service name + tls: boolean; // whether to use HTTPS + transport: string; // "http", "otlp", or "grpc" (default: "http") + baseURL: string; // resolved http(s)://host:port (never includes userinfo) + eventsURL: string; // base + /collectors/{collector}/events + batchURL: string; // base + /collectors/{collector}/events/batch + otlpURL: string; // base + /collectors/{collector}/otlp/logs + tailWSURL: string; // ws(s)://host:port/collectors/{collector}/tail + toString(): string; + toJSON(): Omit; } +const USERINFO_RESERVED = /[:\s]/; +const PASSWORD_RESERVED = /[/:?#\[\]@!$&'()*+,;=]/; + function isLocalhost(host: string): boolean { return host === 'localhost' || host === '127.0.0.1' || host === '::1'; } +export function isPublicDSNUsername(username: string): boolean { + const prefix = 'lx_pub_'; + return username.startsWith(prefix) && username.length > prefix.length; +} + /** * Parse a raw loza:// connection URI into a LozaDSN. * * Validation rules: - * - Scheme must be loza:// - * - Host is required (loza:// or loza:///project are rejected) - * - Project path is required (loza://host is rejected) - * - No userinfo allowed (loza://user:pass@host/project is rejected) + * - Collector path is required (loza://host is rejected) + * - Private userinfo must contain non-empty username/password. + * - Public userinfo is lx_pub_...: with an explicitly empty password. + * - Userinfo is percent-decoded and malformed escapes are rejected. + * - Basic usernames cannot contain ':' or whitespace. + * - Password reserved characters must be percent-encoded. * - tls must be "true", "false", or "auto" * - transport must be "http", "otlp", or "grpc" * - Port must be 1-65535 if specified @@ -66,9 +83,45 @@ export function parse(raw: string): LozaDSN { throw new Error(`invalid Loza DSN: ${e instanceof Error ? e.message : String(e)}`); } - // Reject userinfo (API keys must not be in the URL). - if (url.username || url.password) { - throw new Error('invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead'); + // Parse userinfo from the raw authority before URL normalisation. URL.username + // remains percent-encoded in Node, and inspecting the raw value lets us reject + // unescaped password delimiters without ever retaining credentials in output. + const authority = raw.slice('loza://'.length).split(/[/?#]/, 1)[0] || ''; + const at = authority.lastIndexOf('@'); + let username: string | undefined; + let password: string | undefined; + if (at >= 0) { + const userinfo = authority.slice(0, at); + const separator = userinfo.indexOf(':'); + if (separator < 0 || separator !== userinfo.lastIndexOf(':')) { + throw new Error('invalid Loza DSN: userinfo must be username:password'); + } + const rawUsername = userinfo.slice(0, separator); + const rawPassword = userinfo.slice(separator + 1); + if (!rawUsername) { + throw new Error('invalid Loza DSN: credentials require username:password or lx_pub_...:'); + } + if (PASSWORD_RESERVED.test(rawPassword)) { + throw new Error('invalid Loza DSN: reserved password characters must be percent-encoded'); + } + try { + username = decodeURIComponent(rawUsername); + password = decodeURIComponent(rawPassword); + } catch { + throw new Error('invalid Loza DSN: malformed percent-encoded userinfo'); + } + if (!username || (password === '' && !isPublicDSNUsername(username))) { + throw new Error('invalid Loza DSN: credentials require username:password or lx_pub_...:'); + } + if (USERINFO_RESERVED.test(username)) { + throw new Error('invalid Loza DSN: username must not contain ":" or whitespace'); + } + } + + // Reject userinfo if URL parsing found an authority shape we did not parse. + // (For example, URL may accept an empty userinfo as `url.username/password`.) + if (at < 0 && (url.username || url.password)) { + throw new Error('invalid Loza DSN: malformed userinfo'); } // Strip brackets from IPv6 hostname (URL API may include them for non-standard schemes). @@ -77,10 +130,11 @@ export function parse(raw: string): LozaDSN { throw new Error('invalid Loza DSN: host is required'); } - // Project is the path segment without leading slash. - const project = url.pathname.replace(/^\//, ''); - if (!project) { - throw new Error('invalid Loza DSN: project path is required, e.g. loza://host/my-project'); + // The required path is the canonical collector identity. project remains + // available for compatibility with existing SDK consumers. + const collectorName = url.pathname.replace(/^\//, ''); + if (!collectorName) { + throw new Error('invalid Loza DSN: collector path is required, e.g. loza://host/my-collector'); } // ── TLS default ────────────────────────────────────────────────────────── @@ -142,19 +196,30 @@ export function parse(raw: string): LozaDSN { const hostPart = host.includes(':') ? `[${host}]` : host; const baseURL = `${scheme}://${hostPart}:${port}`; - return { + const collectorPath = encodeURIComponent(collectorName); + const collectorBaseURL = `${baseURL}/collectors/${collectorPath}`; + const collectorTailBaseURL = `${wsScheme}://${hostPart}:${port}/collectors/${collectorPath}`; + const redacted = { scheme: 'loza', host, port, - project, + collectorName, + project: collectorName, env, service, tls, transport, baseURL, - eventsURL: `${baseURL}/events`, - batchURL: `${baseURL}/events/batch`, - otlpURL: `${baseURL}/otlp/logs`, - tailWSURL: `${wsScheme}://${hostPart}:${port}/tail`, + eventsURL: `${collectorBaseURL}/events`, + batchURL: `${collectorBaseURL}/events/batch`, + otlpURL: `${collectorBaseURL}/otlp/logs`, + tailWSURL: `${collectorTailBaseURL}/tail`, + }; + + return { + ...redacted, + ...(username !== undefined ? { username, password } : {}), + toString: () => baseURL, + toJSON: () => redacted, }; } diff --git a/sdks/js/src/config/version.ts b/sdks/js/src/config/version.ts index 9321178..a7ea049 100644 --- a/sdks/js/src/config/version.ts +++ b/sdks/js/src/config/version.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -const FALLBACK_VERSION = '0.2.6'; +const FALLBACK_VERSION = '0.3.0'; /** Resolve the package root directory (two levels up from this file). */ function getPackageRoot(): string { diff --git a/sdks/js/src/core/logger.ts b/sdks/js/src/core/logger.ts index f755d35..73e6fdf 100644 --- a/sdks/js/src/core/logger.ts +++ b/sdks/js/src/core/logger.ts @@ -4,7 +4,7 @@ import { String as StringAttr, } from './event.ts'; import type { Attr, Params } from './event.ts'; -import { defaultConfig, withOptions, dev, production, test } from '../config/config.ts'; +import { collectorRouteURL, defaultConfig, withOptions, dev, production, test, validateConfig } from '../config/config.ts'; import type { Config, ConfigOptions } from '../config/config.ts'; import type { Sink } from '../sinks/sink.ts'; import { HTTPBatchSink } from '../sinks/standard-sinks.ts'; @@ -21,8 +21,10 @@ function resolveSink(cfg: Config): Sink | null { if (cfg.sink) return cfg.sink; if (cfg.collectorUrl) { return new HTTPBatchSink({ - endpoint: cfg.collectorUrl, + endpoint: collectorRouteURL(cfg.collectorUrl, cfg.collectorName, '/events'), apiKey: cfg.apiKey, + username: cfg.username, + password: cfg.password, service: cfg.service, batchSize: cfg.batchSize, flushIntervalMs: cfg.flushIntervalMs, @@ -42,7 +44,7 @@ export class Logger { private _resolvedSink: Sink | null; constructor(cfg?: Partial) { - this.cfg = { ...defaultConfig(), ...cfg }; + this.cfg = validateConfig({ ...defaultConfig(), ...cfg }); this._resolvedSink = resolveSink(this.cfg); } diff --git a/sdks/js/src/index.ts b/sdks/js/src/index.ts index 65cb084..930f3cf 100644 --- a/sdks/js/src/index.ts +++ b/sdks/js/src/index.ts @@ -118,13 +118,13 @@ export type { Level } from './core/level.ts'; // --- Config + Builder --- export { defaultConfig, dev, development, production, test, withOptions, ConfigBuilder, - fromEnv, disabled, + fromEnv, disabled, validateConfig, dev as Dev, development as Development, production as Production, test as Test, WithService, WithAlias, WithVersion, WithEnvironment, WithSink, WithSampler, WithRedactor, WithSchema, WithEventSchema, WithAsync, WithCollectorEndpoint, WithDuplicatePolicy, WithStatsHandler, WithDeploymentID, WithIncludeHost, WithPanicRecovery, - WithApiKey, + WithApiKey, WithBasicAuth, WithRelease, WithNamespace, WithOtelBridge, WithRetry, WithTimeout, WithQueueSize, WithLogger, } from './config/config.ts'; export type { Config, AsyncConfig, SecurityConfig, ConfigOptions } from './config/config.ts'; diff --git a/sdks/js/src/sinks/standard-sinks.ts b/sdks/js/src/sinks/standard-sinks.ts index fc1f8fa..b1ee0ec 100644 --- a/sdks/js/src/sinks/standard-sinks.ts +++ b/sdks/js/src/sinks/standard-sinks.ts @@ -6,8 +6,8 @@ import { gzip as zlibGzip } from 'node:zlib'; import { promisify } from 'node:util'; import { parseCollectorResponse } from '../generated/spec-contract.ts'; import type { CollectorResponse } from '../generated/spec-contract.ts'; +import { isPublicDSNUsername } from '../config/dsn.ts'; import { SDK_VERSION } from '../config/version.ts'; - const gzipAsync = promisify(zlibGzip); /** Stats handler for collector acknowledgements. */ @@ -157,6 +157,8 @@ export class MemorySink implements Sink { export class HTTPBatchSink implements Sink { private endpoint: string; private apiKey: string; + private username: string; + private password: string; private authHeader: string; private sdkName: string; private sdkVersion: string; @@ -177,6 +179,8 @@ export class HTTPBatchSink implements Sink { constructor(opts: { endpoint: string; apiKey?: string; + username?: string; + password?: string; authHeader?: string; sdkName?: string; sdkVersion?: string; @@ -192,7 +196,24 @@ export class HTTPBatchSink implements Sink { statsHandler?: StatsHandler; }) { this.endpoint = opts.endpoint; + const endpoint = new URL(this.endpoint); + if (endpoint.username || endpoint.password) { + throw new Error('invalid HTTP sink endpoint: credentials must not be embedded in the URL'); + } this.apiKey = opts.apiKey || ''; + this.username = opts.username || ''; + this.password = opts.password || ''; + if (!this.username && this.password) { + throw new Error('invalid HTTP sink options: Basic password requires a username'); + } + if (this.username && !this.password && !isPublicDSNUsername(this.username)) { + throw new Error('invalid HTTP sink options: Basic credentials require a password unless username is an lx_pub_ capability'); + } + if (!this.apiKey && this.username && + endpoint.protocol === 'http:' && + !['localhost', '127.0.0.1', '::1'].includes(endpoint.hostname)) { + throw new Error('invalid HTTP sink options: Basic credentials require HTTPS (HTTP is allowed only for localhost)'); + } this.authHeader = opts.authHeader || 'Authorization'; this.sdkName = opts.sdkName || 'loza-js'; this.sdkVersion = opts.sdkVersion || SDK_VERSION; @@ -369,13 +390,15 @@ export class HTTPBatchSink implements Sink { const headers: Record = { 'Content-Type': this.ndjson ? 'application/x-ndjson' : 'application/json', 'Content-Length': body.length.toString(), + ...(this.enableCompression ? { 'Content-Encoding': 'gzip' } : {}), }; if (this.apiKey) { headers[this.authHeader] = this.authHeader.toLowerCase() === 'authorization' ? `Bearer ${this.apiKey}` : this.apiKey; + } else if (this.username && (this.password || isPublicDSNUsername(this.username))) { + headers.Authorization = `Basic ${Buffer.from(`${this.username}:${this.password}`, 'utf8').toString('base64')}`; } - if (this.enableCompression) headers['Content-Encoding'] = 'gzip'; const req = mod.request({ hostname: url.hostname, @@ -428,8 +451,9 @@ function sleep(ms: number): Promise { export interface HTTPBatchSinkOptions { endpoint: string; apiKey?: string; + username?: string; + password?: string; authHeader?: string; - sdkName?: string; sdkVersion?: string; service?: string; timeout?: number; diff --git a/sdks/js/tests/collector-routing.test.ts b/sdks/js/tests/collector-routing.test.ts new file mode 100644 index 0000000..3d6d428 --- /dev/null +++ b/sdks/js/tests/collector-routing.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { test } from 'node:test'; +import { CollectorClient } from '../src/collector/client.ts'; + +test('public Basic credentials reach a collector-scoped endpoint without URL userinfo', async () => { + const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN'; + let requestPath = ''; + let authorization = ''; + const server = createServer((request, response) => { + requestPath = request.url ?? ''; + authorization = request.headers.authorization ?? ''; + response.writeHead(202, { 'content-type': 'application/json' }); + response.end('{"request_id":"req_1","status":"accepted","accepted":1,"rejected":0,"invalid":0,"acks":[]}'); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('test server did not expose a TCP address'); + } + const { port } = address; + + try { + const client = new CollectorClient({ + url: `http://127.0.0.1:${port}`, + collectorName: 'public-collector', + username: capability, + password: '', + enableCompression: false, + }); + await client.sendBatch([{ event: 'checkout.completed' }]); + } finally { + server.close(); + await once(server, 'close'); + } + + assert.equal(requestPath, '/collectors/public-collector/events'); + assert.equal(authorization, `Basic ${Buffer.from(`${capability}:`, 'utf8').toString('base64')}`); + assert.equal(requestPath.includes(capability), false); +}); diff --git a/sdks/js/tests/config-file.test.ts b/sdks/js/tests/config-file.test.ts index 2714dbc..d581fae 100644 --- a/sdks/js/tests/config-file.test.ts +++ b/sdks/js/tests/config-file.test.ts @@ -91,11 +91,14 @@ describe('mergeFileConfig', () => { assert.equal(result.collectorUrl, 'http://collector:9308'); }); - it('parses loza:// DSN in collector_url', () => { + it('parses a credentialed DSN as a scoped, credential-free config', () => { const base = defaultConfig(); - const raw = { collector_url: 'loza://localhost:9308/my-project' }; + const raw = { collector_url: 'loza://private-user:private-secret@localhost:9308/my-project' }; const result = mergeFileConfig(base, raw); assert.equal(result.collectorUrl, 'http://localhost:9308'); + assert.equal(result.collectorName, 'my-project'); + assert.equal(result.username, 'private-user'); + assert.equal(result.password, 'private-secret'); }); it('extracts env from DSN when environment is default', () => { @@ -105,6 +108,18 @@ describe('mergeFileConfig', () => { assert.equal(result.environment, 'staging'); }); + it('accepts a public DSN capability with an intentionally empty password', () => { + const base = defaultConfig(); + const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN'; + const result = mergeFileConfig(base, { + collector_url: `loza://${capability}:@localhost:9308/public-collector`, + }); + assert.equal(result.collectorName, 'public-collector'); + assert.equal(result.username, capability); + assert.equal(result.password, ''); + assert.equal(result.collectorUrl.includes(capability), false); + }); + it('applies batch_size to batchSize', () => { const base = defaultConfig(); const raw = { batch_size: 200 }; diff --git a/sdks/js/tests/dsn.test.ts b/sdks/js/tests/dsn.test.ts index b30c2e9..79d71d7 100644 --- a/sdks/js/tests/dsn.test.ts +++ b/sdks/js/tests/dsn.test.ts @@ -31,3 +31,45 @@ describe('loza:// DSN parser', () => { } } }); + +it('routes private credentials to a collector-scoped endpoint without retaining userinfo', () => { + const dsn = parse('loza://key%40id:secret%3Avalue@example.com/project'); + assert.equal(dsn.collectorName, 'project'); + assert.equal(dsn.project, 'project'); + assert.equal(dsn.username, 'key@id'); + assert.equal(dsn.password, 'secret:value'); + assert.equal(dsn.baseURL, 'https://example.com:443'); + assert.equal(dsn.eventsURL, 'https://example.com:443/collectors/project/events'); + assert.equal(dsn.baseURL.includes('secret'), false); +}); + +it('rejects empty, malformed, and unescaped credential components', () => { + for (const raw of [ + 'loza://:secret@example.com/project', + 'loza://key:@example.com/project', + 'loza://key:secret%2@example.com/project', + 'loza://key:secret/part@example.com/project', + ]) { + assert.throws(() => parse(raw), /invalid Loza DSN/); + } +}); + +it('redacts credentials from string and JSON representations', () => { + const dsn = parse('loza://key-id:s%40cret%3Avalue@example.com/project'); + assert.equal(dsn.toString().includes('s@cret:value'), false); + assert.equal(JSON.stringify(dsn).includes('s@cret:value'), false); + assert.equal(JSON.stringify(dsn).includes('key-id'), false); +}); + +it('routes and redacts public bearer credentials', () => { + const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN'; + const dsn = parse(`loza://${capability}:@example.com/public-collector`); + assert.equal(dsn.username, capability); + assert.equal(dsn.password, ''); + assert.equal(dsn.eventsURL, 'https://example.com:443/collectors/public-collector/events'); + assert.equal(dsn.batchURL, 'https://example.com:443/collectors/public-collector/events/batch'); + assert.equal(dsn.otlpURL, 'https://example.com:443/collectors/public-collector/otlp/logs'); + assert.equal(dsn.tailWSURL, 'wss://example.com:443/collectors/public-collector/tail'); + assert.equal(dsn.toString().includes(capability), false); + assert.equal(JSON.stringify(dsn).includes(capability), false); +}); diff --git a/sdks/py/README.md b/sdks/py/README.md index f6d231d..8886e0c 100644 --- a/sdks/py/README.md +++ b/sdks/py/README.md @@ -1,6 +1,6 @@ # LOZA Python SDK -**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance +**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance Full API conformance with specification is complete. See [SDK_CONFORMANCE_CONTRACT.md](../../spec/docs/SDK_CONFORMANCE_CONTRACT.md) for detailed guarantees. diff --git a/sdks/py/docs/sdk-parity-manifest.json b/sdks/py/docs/sdk-parity-manifest.json index b0da7b2..3cfee69 100644 --- a/sdks/py/docs/sdk-parity-manifest.json +++ b/sdks/py/docs/sdk-parity-manifest.json @@ -1,8 +1,8 @@ { - "version": "0.2.6", + "version": "0.3.0", "package": "loza", "scope": "lightweight-sdk", - "stability": "product-parity-v0.2.6", + "stability": "product-parity-v0.3.0", "policy": "full-product-method-family", "sdks": ["go", "python", "rust", "javascript"], "lifecycle": [ diff --git a/sdks/py/loza-py.yaml b/sdks/py/loza-py.yaml index 2b1ed9b..44f3211 100644 --- a/sdks/py/loza-py.yaml +++ b/sdks/py/loza-py.yaml @@ -1,7 +1,7 @@ name: loza-py kind: sdk language: python -version: 0.2.6 +version: 0.3.0 description: Python SDK for LOZA license: MIT repository: https://github.com/Astraive/loza diff --git a/sdks/py/pyproject.toml b/sdks/py/pyproject.toml index f5afd48..f0a1294 100644 --- a/sdks/py/pyproject.toml +++ b/sdks/py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loza" -version = "0.2.6" +version = "0.3.0" description = "Python LOZA wide-event SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/sdks/py/src/loza/core/client.py b/sdks/py/src/loza/core/client.py index cca531a..0cc2538 100644 --- a/sdks/py/src/loza/core/client.py +++ b/sdks/py/src/loza/core/client.py @@ -70,7 +70,15 @@ def _install_default_collector_sink(self) -> None: if not self._config.sinks or all(self._is_default_terminal_sink(sink) for sink in self._config.sinks): self._config = replace( self._config, - sinks=[HTTPBatchSink(_collector_ingest_endpoint(endpoint), api_key=self._config.api_key, service=self._config.service)], + sinks=[ + HTTPBatchSink( + _collector_ingest_endpoint(endpoint, self._config.collector_name), + api_key=self._config.api_key, + username=self._config.username, + password=self._config.password, + service=self._config.service, + ) + ], ) @staticmethod diff --git a/sdks/py/src/loza/core/config.py b/sdks/py/src/loza/core/config.py index 67124ec..7ce9cf3 100644 --- a/sdks/py/src/loza/core/config.py +++ b/sdks/py/src/loza/core/config.py @@ -4,6 +4,7 @@ from pathlib import Path from dataclasses import dataclass, field, replace from typing import Any, Callable, Protocol, runtime_checkable +from urllib.parse import quote from ..sinks.stdout import StdoutSink from ..generated.spec_contract import LOZA_EVENT_VERSION, LOZA_INGEST_API_VERSION, LOZA_SPEC_VERSION @@ -99,7 +100,10 @@ class Config: strict: bool = False sinks: list[Any] = field(default_factory=list) collector_endpoint: str = "" + collector_name: str = "" api_key: str = "" + username: str = field(default="", repr=False) + password: str = field(default="", repr=False) duplicate_policy: str = "canonical_wins" schema: Any = None sampler: Any = None @@ -173,6 +177,12 @@ def with_async(self, enabled: bool) -> "Config": def with_collector_endpoint(self, endpoint: str) -> "Config": return replace(self, collector_endpoint=endpoint) + def with_collector_name(self, collector_name: str) -> "Config": + return replace(self, collector_name=collector_name) + + def with_basic_auth(self, username: str, password: str) -> "Config": + return replace(self, username=username, password=password) + def with_api_key(self, api_key: str) -> "Config": return replace(self, api_key=api_key.strip()) @@ -215,6 +225,8 @@ def from_env(cls) -> "Config": return _apply_env_vars(cls()) def validate(self) -> None: + if self.collector_endpoint.strip().startswith("loza://"): + _apply_dsn(self, self.collector_endpoint.strip(), include_credentials=not bool(self.api_key)) if self.level not in {"debug", "info", "notice", "warn", "error", "fatal"}: raise ValueError(f"unsupported level: {self.level}") if self.async_config.queue_size <= 0: @@ -225,6 +237,18 @@ def validate(self) -> None: raise ValueError("max_event_bytes must be positive") if self.strict and not self.service: raise ValueError("strict mode requires service") + from .dsn import is_public_dsn_username + + if not self.username and self.password: + raise ValueError("collector basic auth password requires a username") + if self.username and not self.password and not is_public_dsn_username(self.username): + raise ValueError("collector basic auth requires a password unless username is an lx_pub_ capability") + if self.username and self.collector_endpoint.lower().startswith("http://"): + from urllib.parse import urlparse + + host = (urlparse(self.collector_endpoint).hostname or "").lower() + if host not in {"localhost", "127.0.0.1", "::1"}: + raise ValueError("collector basic auth requires HTTPS except for local endpoints") def load_layered_config() -> Config: @@ -269,15 +293,51 @@ def new_client(code_config: Config): # -> Logger return Logger(merged) -def _collector_ingest_endpoint(endpoint: str) -> str: +def _collector_ingest_endpoint(endpoint: str, collector_name: str = "") -> str: endpoint = endpoint.strip().rstrip("/") + if collector_name: + return f"{endpoint}/collectors/{quote(collector_name, safe='')}/events" if endpoint.endswith("/events"): return endpoint return f"{endpoint}/events" +def _apply_dsn(cfg: Config, raw: str, *, include_credentials: bool = True) -> None: + from .dsn import parse + + dsn = parse(raw) + cfg.collector_endpoint = dsn.base_url + cfg.collector_name = dsn.collector_name + if dsn.env != "default": + cfg.environment = dsn.env + if dsn.service: + cfg.service = dsn.service + if include_credentials and dsn.username and not cfg.api_key and not cfg.username: + cfg.username = dsn.username + cfg.password = dsn.password + + +def _resolve_endpoint_source(cfg: Config, raw: str, *, include_credentials: bool) -> None: + raw = raw.strip() + if raw.startswith("loza://"): + _apply_dsn(cfg, raw, include_credentials=include_credentials) + else: + cfg.collector_endpoint = raw + + def _apply_env_vars(cfg: Config) -> Config: """Apply environment variables to config, overriding file values.""" + dsn_raw = os.getenv("LOZA_DSN", "").strip() + if dsn_raw: + _resolve_endpoint_source(cfg, dsn_raw, include_credentials=True) + + collector_url = os.getenv("LOZA_COLLECTOR_URL", "").strip() + collector_endpoint = os.getenv("LOZA_COLLECTOR_ENDPOINT", "").strip() + if collector_url: + _resolve_endpoint_source(cfg, collector_url, include_credentials=False) + elif collector_endpoint: + _resolve_endpoint_source(cfg, collector_endpoint, include_credentials=False) + env_map = { "LOZA_SERVICE": "service", "LOZA_SERVICE_NAME": "service", @@ -285,8 +345,6 @@ def _apply_env_vars(cfg: Config) -> Config: "LOZA_ENVIRONMENT": "environment", "LOZA_REGION": "region", "LOZA_LOG_LEVEL": "level", - "LOZA_COLLECTOR_URL": "collector_endpoint", - "LOZA_COLLECTOR_ENDPOINT": "collector_endpoint", "LOZA_API_KEY": "api_key", "LOZA_DUPLICATE_POLICY": "duplicate_policy", } @@ -342,7 +400,14 @@ def _merge_file_config(base: Config, file_cfg: Config) -> Config: if file_cfg.strict: base.strict = file_cfg.strict if file_cfg.collector_endpoint and not base.collector_endpoint: - base.collector_endpoint = file_cfg.collector_endpoint + _resolve_endpoint_source(base, file_cfg.collector_endpoint, include_credentials=True) + if file_cfg.collector_name and not base.collector_name: + base.collector_name = file_cfg.collector_name + if file_cfg.api_key and not base.api_key: + base.api_key = file_cfg.api_key + if file_cfg.username and not base.username and not base.api_key: + base.username = file_cfg.username + base.password = file_cfg.password if file_cfg.duplicate_policy and file_cfg.duplicate_policy != CanonicalWins: base.duplicate_policy = file_cfg.duplicate_policy if file_cfg.checkpoint_emit_immediately: @@ -365,7 +430,14 @@ def _merge_code_config(base: Config, code: Config) -> Config: if code.strict: base.strict = code.strict if code.collector_endpoint: - base.collector_endpoint = code.collector_endpoint + _resolve_endpoint_source(base, code.collector_endpoint, include_credentials=not bool(code.api_key)) + if code.api_key: + base.api_key = code.api_key.strip() + if code.collector_name: + base.collector_name = code.collector_name + if code.username and not code.api_key: + base.username = code.username + base.password = code.password if code.duplicate_policy and code.duplicate_policy != CanonicalWins: base.duplicate_policy = code.duplicate_policy if code.sinks: @@ -430,6 +502,10 @@ def _config_from_mapping(data: dict[str, Any]) -> Config: level=str(data.get("level", "info")), strict=bool(data.get("strict", False)), collector_endpoint=str(data.get("collector_endpoint", "")), + collector_name=str(data.get("collector_name", "")), + api_key=str(data.get("api_key", "")), + username=str(data.get("username", "")), + password=str(data.get("password", "")), duplicate_policy=str(data.get("duplicate_policy", CanonicalWins)), checkpoint_emit_immediately=bool(data.get("checkpoint_emit_immediately", False)), async_config=AsyncConfig( diff --git a/sdks/py/src/loza/core/dsn.py b/sdks/py/src/loza/core/dsn.py index ce9cd17..0731801 100644 --- a/sdks/py/src/loza/core/dsn.py +++ b/sdks/py/src/loza/core/dsn.py @@ -5,14 +5,18 @@ Format:: - loza://[host][:port]/[project]?env=&service=&tls=&transport= + loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport= + +Private credentials use username:password. Public `lx_pub_...` bearer +capabilities use an explicitly empty password (`lx_pub_...:`). + """ from __future__ import annotations import os -from dataclasses import dataclass -from urllib.parse import urlparse, parse_qs +from dataclasses import dataclass, field +from urllib.parse import parse_qs, quote, unquote_to_bytes, urlparse @dataclass(frozen=True, slots=True) @@ -22,6 +26,7 @@ class LozaDSN: scheme: str host: str port: int + collector_name: str project: str env: str service: str @@ -32,6 +37,37 @@ class LozaDSN: batch_url: str otlp_url: str tail_ws_url: str + username: str = field(default="", repr=False) + password: str = field(default="", repr=False) + + def __repr__(self) -> str: + credentials = "" if self.username else "" + return ( + "LozaDSN(" + f"scheme={self.scheme!r}, host={self.host!r}, port={self.port!r}, " + f"collector_name={self.collector_name!r}, project={self.project!r}, " + f"env={self.env!r}, service={self.service!r}, tls={self.tls!r}, " + f"transport={self.transport!r}, base_url={self.base_url!r}, " + f"events_url={self.events_url!r}, batch_url={self.batch_url!r}, " + f"otlp_url={self.otlp_url!r}, tail_ws_url={self.tail_ws_url!r}, " + f"credentials={credentials!r})" + ) + + __str__ = __repr__ + + +_HEX = frozenset("0123456789abcdefABCDEF") +_PASSWORD_RESERVED = frozenset(":/?#[]@!$&'()*+,;=") + + +def _decode_userinfo(value: str, label: str) -> str: + for index, char in enumerate(value): + if char == "%" and (index + 2 >= len(value) or value[index + 1] not in _HEX or value[index + 2] not in _HEX): + raise ValueError(f"invalid Loza DSN: malformed percent-encoding in {label}") + try: + return unquote_to_bytes(value).decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"invalid Loza DSN: {label} must be valid UTF-8") from exc _LOCALHOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) @@ -41,6 +77,11 @@ def _is_localhost(host: str) -> bool: return host in _LOCALHOSTS +def is_public_dsn_username(username: str) -> bool: + prefix = "lx_pub_" + return username.startswith(prefix) and len(username) > len(prefix) + + def parse(raw: str) -> LozaDSN: """Parse a raw loza:// connection URI into a LozaDSN. @@ -53,13 +94,30 @@ def parse(raw: str) -> LozaDSN: if not raw.startswith("loza://"): raise ValueError("invalid Loza DSN: scheme must be loza://") - u = urlparse(raw) - - # Reject userinfo (API keys must not be in the URL). - if u.username: - raise ValueError( - "invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead" - ) + try: + u = urlparse(raw) + except ValueError as exc: + raise ValueError("invalid Loza DSN: malformed URL") from exc + + username = "" + password = "" + if "@" in u.netloc: + raw_userinfo = u.netloc.rsplit("@", 1)[0] + if ":" not in raw_userinfo: + raise ValueError("invalid Loza DSN: userinfo must include username and password") + raw_username, raw_password = raw_userinfo.split(":", 1) + if not raw_username: + raise ValueError("invalid Loza DSN: credentials require username:password or lx_pub_...:") + if any(char in _PASSWORD_RESERVED for char in raw_password): + raise ValueError("invalid Loza DSN: reserved password characters must be percent-encoded") + username = _decode_userinfo(raw_username, "username") + password = _decode_userinfo(raw_password, "password") + if not username or (not password and not is_public_dsn_username(username)): + raise ValueError("invalid Loza DSN: credentials require username:password or lx_pub_...:") + if ":" in username or any(char.isspace() for char in username): + raise ValueError("invalid Loza DSN: username must not contain ':' or whitespace") + elif u.username is not None or u.password is not None: + raise ValueError("invalid Loza DSN: malformed userinfo") host = u.hostname or "" if not host: @@ -71,12 +129,11 @@ def parse(raw: str) -> LozaDSN: except ValueError: raise ValueError("invalid Loza DSN: invalid port") - # Project is the path segment without leading slash. - project = u.path.lstrip("/") - if not project: - raise ValueError( - "invalid Loza DSN: project path is required, e.g. loza://host/my-project" - ) + # The required path is the canonical collector identity. project remains + # a compatibility alias for existing SDK consumers. + collector_name = u.path.lstrip("/") + if not collector_name: + raise ValueError("invalid Loza DSN: collector path is required, e.g. loza://host/my-collector") q = parse_qs(u.query) @@ -91,9 +148,7 @@ def parse(raw: str) -> LozaDSN: elif tls_val == "auto": pass # keep computed default else: - raise ValueError( - f"invalid Loza DSN: tls must be true, false, or auto, got {tls_val!r}" - ) + raise ValueError(f"invalid Loza DSN: tls must be true, false, or auto, got {tls_val!r}") # --- Port default ------------------------------------------------------- if tls: @@ -116,9 +171,7 @@ def parse(raw: str) -> LozaDSN: if transport_val in ("http", "otlp", "grpc"): transport = transport_val else: - raise ValueError( - f"invalid Loza DSN: transport must be http, otlp, or grpc, got {transport_val!r}" - ) + raise ValueError(f"invalid Loza DSN: transport must be http, otlp, or grpc, got {transport_val!r}") # --- Env ---------------------------------------------------------------- env = q.get("env", ["default"])[0] @@ -135,21 +188,27 @@ def parse(raw: str) -> LozaDSN: host_part = f"[{host}]" if ":" in host else host base_url = f"{scheme}://{host_part}:{port}" + collector_path = quote(collector_name, safe="") + collector_base_url = f"{base_url}/collectors/{collector_path}" + collector_tail_base_url = f"{ws_scheme}://{host_part}:{port}/collectors/{collector_path}" return LozaDSN( scheme="loza", host=host, port=port, - project=project, + collector_name=collector_name, + project=collector_name, env=env, service=service, tls=tls, transport=transport, base_url=base_url, - events_url=base_url + "/events", - batch_url=base_url + "/events/batch", - otlp_url=base_url + "/otlp/logs", - tail_ws_url=f"{ws_scheme}://{host_part}:{port}/tail", + events_url=collector_base_url + "/events", + batch_url=collector_base_url + "/events/batch", + otlp_url=collector_base_url + "/otlp/logs", + tail_ws_url=collector_tail_base_url + "/tail", + username=username, + password=password, ) diff --git a/sdks/py/src/loza/core/http_client.py b/sdks/py/src/loza/core/http_client.py index c4bbbec..ad0bc45 100644 --- a/sdks/py/src/loza/core/http_client.py +++ b/sdks/py/src/loza/core/http_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import json import socket import time @@ -19,6 +20,7 @@ validate_event_payload, ) from ..version import SDK_VERSION +from .dsn import is_public_dsn_username class InstrumentedHTTPClient: @@ -131,8 +133,11 @@ def __init__( sdk_name: str = "loza-py", sdk_version: str = SDK_VERSION, service: str = "", + username: str = "", + password: str = "", ) -> None: _validate_collector_endpoint(endpoint) + _validate_basic_auth_endpoint(endpoint, username, password) self.endpoint = endpoint self.api_key = api_key self.auth_header = auth_header @@ -141,6 +146,18 @@ def __init__( self.sdk_name = sdk_name self.sdk_version = sdk_version self.service = service + self.username = username + self.password = password + + def _auth_headers(self) -> dict[str, str]: + if self.api_key: + if self.auth_header.lower() == "authorization": + return {self.auth_header: f"Bearer {self.api_key}"} + return {self.auth_header: self.api_key} + if self.username or self.password: + token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {token}"} + return {} def envelope(self, encoded_events: Iterable[str]) -> bytes: return _build_ingest_body(encoded_events, self.sdk_name, self.sdk_version, self.service) @@ -148,11 +165,7 @@ def envelope(self, encoded_events: Iterable[str]) -> bytes: def send_batch(self, encoded_events: Iterable[str]) -> CollectorResponse: body = self.envelope(encoded_events) headers = {"content-type": "application/json"} - if self.api_key: - if self.auth_header.lower() == "authorization": - headers[self.auth_header] = f"Bearer {self.api_key}" - else: - headers[self.auth_header] = self.api_key + headers.update(self._auth_headers()) request = Request(self.endpoint, data=body, headers=headers, method="POST") last_error: Exception | None = None for attempt in range(self.retries + 1): @@ -193,7 +206,7 @@ def _base_url(self) -> str: def health(self) -> bool: """Check if the collector is healthy.""" for path in ("/health", "/healthz"): - req = Request(f"{self._base_url()}{path}", method="GET") + req = Request(f"{self._base_url()}{path}", headers=self._auth_headers(), method="GET") try: with urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read()) @@ -206,7 +219,7 @@ def health(self) -> bool: def ready(self) -> bool: """Check if the collector is ready to accept requests.""" for path in ("/ready", "/readyz"): - req = Request(f"{self._base_url()}{path}", method="GET") + req = Request(f"{self._base_url()}{path}", headers=self._auth_headers(), method="GET") try: with urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read()) @@ -218,18 +231,13 @@ def ready(self) -> bool: def version(self) -> dict: """Fetch version info from the collector.""" - req = Request(f"{self._base_url()}/version", method="GET") + req = Request(f"{self._base_url()}/version", headers=self._auth_headers(), method="GET") with urlopen(req, timeout=self.timeout) as resp: return json.loads(resp.read()) def status(self) -> dict: """Fetch operational status from the collector.""" - req = Request(f"{self._base_url()}/status", method="GET") - if self.api_key: - if self.auth_header.lower() == "authorization": - req.add_header(self.auth_header, f"Bearer {self.api_key}") - else: - req.add_header(self.auth_header, self.api_key) + req = Request(f"{self._base_url()}/status", headers=self._auth_headers(), method="GET") with urlopen(req, timeout=self.timeout) as resp: return json.loads(resp.read()) @@ -258,7 +266,7 @@ def tail_lines( if params: url += "?" + urlencode(params) - request = Request(url, headers={"accept": "application/x-ndjson"}) + request = Request(url, headers={"accept": "application/x-ndjson", **self._auth_headers()}) with urlopen(request, timeout=timeout) as response: for raw in response: line = raw.decode("utf-8").strip() @@ -270,9 +278,15 @@ def tail_lines( def _validate_collector_endpoint(endpoint: str) -> None: - parsed = urlparse(endpoint) + try: + parsed = urlparse(endpoint) + parsed.port + except ValueError as exc: + raise ValueError("collector endpoint is malformed") from exc if parsed.scheme not in {"http", "https"}: raise ValueError("collector endpoint must use http or https") + if parsed.username is not None or parsed.password is not None: + raise ValueError("collector endpoint must not contain credentials") if not parsed.hostname: raise ValueError("collector endpoint must include a host") if _private_endpoint_allowed(): @@ -291,6 +305,18 @@ def _validate_collector_endpoint(endpoint: str) -> None: raise ValueError("collector endpoint resolves to a non-public address") +def _validate_basic_auth_endpoint(endpoint: str, username: str, password: str) -> None: + if not username and password: + raise ValueError("collector basic auth password requires a username") + if username and not password and not is_public_dsn_username(username): + raise ValueError("collector basic auth requires a password unless username is an lx_pub_ capability") + if not username: + return + parsed = urlparse(endpoint) + if parsed.scheme == "http" and (parsed.hostname or "").lower() not in {"localhost", "127.0.0.1", "::1"}: + raise ValueError("collector basic auth requires HTTPS except for local endpoints") + + def _private_endpoint_allowed() -> bool: return getenv("LOZA_ALLOW_PRIVATE_COLLECTOR_ENDPOINTS", "").lower() in {"1", "true", "yes"} @@ -324,11 +350,7 @@ def _collector_request( url += "?" + urlencode(params) data = json.dumps(body).encode("utf-8") if body is not None else None headers = {"content-type": "application/json"} if body else {} - if client.api_key: - if client.auth_header.lower() == "authorization": - headers[client.auth_header] = f"Bearer {client.api_key}" - else: - headers[client.auth_header] = client.api_key + headers.update(client._auth_headers()) req = Request(url, data=data, headers=headers, method=method) with urlopen(req, timeout=client.timeout) as resp: raw = resp.read() diff --git a/sdks/py/src/loza/core/logger.py b/sdks/py/src/loza/core/logger.py index df1177c..3de1c74 100644 --- a/sdks/py/src/loza/core/logger.py +++ b/sdks/py/src/loza/core/logger.py @@ -72,7 +72,15 @@ def _install_default_collector_sink(self) -> None: if not self._config.sinks or all(self._is_default_terminal_sink(sink) for sink in self._config.sinks): self._config = replace( self._config, - sinks=[HTTPBatchSink(_collector_ingest_endpoint(endpoint), api_key=self._config.api_key, service=self._config.service)], + sinks=[ + HTTPBatchSink( + _collector_ingest_endpoint(endpoint, self._config.collector_name), + api_key=self._config.api_key, + username=self._config.username, + password=self._config.password, + service=self._config.service, + ) + ], ) @staticmethod diff --git a/sdks/py/src/loza/core/lql.py b/sdks/py/src/loza/core/lql.py index 39e886d..267016f 100644 --- a/sdks/py/src/loza/core/lql.py +++ b/sdks/py/src/loza/core/lql.py @@ -61,7 +61,9 @@ def query_lql(endpoint: str, lql: str, *, api_key: str = "", timeout: float = 30 ) -def query_sql(endpoint: str, sql: str, *, engine: str = "duckdb", api_key: str = "", timeout: float = 30.0) -> QueryResult: +def query_sql( + endpoint: str, sql: str, *, engine: str = "duckdb", api_key: str = "", timeout: float = 30.0 +) -> QueryResult: """Execute a raw SQL query against the collector. Args: diff --git a/sdks/py/src/loza/sinks/httpbatch/httpbatch.py b/sdks/py/src/loza/sinks/httpbatch/httpbatch.py index a146aad..bceef40 100644 --- a/sdks/py/src/loza/sinks/httpbatch/httpbatch.py +++ b/sdks/py/src/loza/sinks/httpbatch/httpbatch.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import gzip as gzip_mod import time import urllib.request @@ -19,6 +20,7 @@ _parse_collector_response_body, _parse_retry_after, _retry_delay, + _validate_basic_auth_endpoint, _validate_collector_endpoint, ) from ...version import SDK_VERSION @@ -35,6 +37,8 @@ class HTTPBatchSink: endpoint: str api_key: str = "" + username: str = "" + password: str = "" auth_header: str = "Authorization" sdk_name: str = "loza-py" sdk_version: str = SDK_VERSION @@ -50,6 +54,17 @@ class HTTPBatchSink: def __post_init__(self) -> None: _validate_collector_endpoint(self.endpoint) + _validate_basic_auth_endpoint(self.endpoint, self.username, self.password) + + def _auth_headers(self) -> dict[str, str]: + if self.api_key: + if self.auth_header.lower() == "authorization": + return {self.auth_header: f"Bearer {self.api_key}"} + return {self.auth_header: self.api_key} + if self.username or self.password: + token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {token}"} + return {} def write(self, encoded: str) -> None: self.write_batch([encoded]) @@ -69,11 +84,7 @@ def write_batch(self, encoded_events: Iterable[str]) -> None: payload = gzip_mod.compress(payload) headers["content-encoding"] = "gzip" - if self.api_key: - if self.auth_header.lower() == "authorization": - headers[self.auth_header] = f"Bearer {self.api_key}" - else: - headers[self.auth_header] = self.api_key + headers.update(self._auth_headers()) req = urllib.request.Request(self.endpoint, data=payload, headers=headers, method="POST") last_error: Exception | None = None diff --git a/sdks/py/src/loza/version.py b/sdks/py/src/loza/version.py index 0de0dd7..2c51232 100644 --- a/sdks/py/src/loza/version.py +++ b/sdks/py/src/loza/version.py @@ -5,7 +5,7 @@ from __future__ import annotations -_FALLBACK_VERSION = "0.2.6" +_FALLBACK_VERSION = "0.3.0" def _load_version() -> str: diff --git a/sdks/py/src/loza_contract.py b/sdks/py/src/loza_contract.py index 7aa0b55..98bc2a4 100644 --- a/sdks/py/src/loza_contract.py +++ b/sdks/py/src/loza_contract.py @@ -6,7 +6,7 @@ from typing import Any, Iterable CONTRACT = { - "product_version": "0.2.6", + "product_version": "0.3.0", "spec_version": "v1", "api_version": "v1", "event_version": "v1", @@ -298,7 +298,7 @@ }, "fixtures": { "version": "v1", - "product_version": "0.2.6", + "product_version": "0.3.0", "strict_schema": "../schema/event.strict.schema.json", "loose_schema": "../schema/event.loose.schema.json", "valid": [ diff --git a/sdks/py/tests/integration/test_e2e.py b/sdks/py/tests/integration/test_e2e.py index 75e50de..e8a7c3d 100644 --- a/sdks/py/tests/integration/test_e2e.py +++ b/sdks/py/tests/integration/test_e2e.py @@ -1,4 +1,5 @@ """Integration tests for Python SDK with collector.""" + import json import time import loza @@ -23,11 +24,7 @@ def test_event_integrity_through_pipeline() -> None: logger = loza.New(loza.Test("test_service")) # Create event with specific values - ctx = logger.start_event(loza.Params( - event="integrity_test", - message="Test message", - level="info" - )) + ctx = logger.start_event(loza.Params(event="integrity_test", message="Test message", level="info")) logger.enrich(ctx, loza.String("user_id", "user123")) logger.finish(ctx, "success") payload = logger.emit(ctx) @@ -64,10 +61,7 @@ def test_error_event_collection() -> None: """Verify error events are properly collected.""" logger = loza.New(loza.Test("error_test")) - ctx = logger.start_event(loza.Params( - event="error_event", - message="Something went wrong" - )) + ctx = logger.start_event(loza.Params(event="error_event", message="Something went wrong")) logger.enrich(ctx, loza.String("error_code", "E001")) logger.finish(ctx, "error") payload = logger.emit(ctx) @@ -127,9 +121,7 @@ def test_canonical_fields_immutable() -> None: def test_sampling_in_pipeline() -> None: """Verify sampling decision is consistent through pipeline.""" # Create logger with deterministic sampling - logger = loza.New( - loza.Test("sample_test").with_sampler(loza.SampleAll()) - ) + logger = loza.New(loza.Test("sample_test").with_sampler(loza.SampleAll())) for i in range(3): ctx = logger.start_event(loza.Params(event=f"sampled_{i}")) diff --git a/sdks/py/tests/test_alias.py b/sdks/py/tests/test_alias.py index 3ffa69f..085664e 100644 --- a/sdks/py/tests/test_alias.py +++ b/sdks/py/tests/test_alias.py @@ -1,4 +1,5 @@ """Tests for create_loza and alias functions.""" + import json import loza diff --git a/sdks/py/tests/test_behavior.py b/sdks/py/tests/test_behavior.py index 27c0095..d652586 100644 --- a/sdks/py/tests/test_behavior.py +++ b/sdks/py/tests/test_behavior.py @@ -11,10 +11,7 @@ def test_schema_redaction_and_flattening() -> None: sink = loza.MemorySink() cfg = ( - loza.Test("checkout") - .with_sink(sink) - .with_schema(loza.FlatSchema()) - .with_redactor(loza.RedactKeys("password")) + loza.Test("checkout").with_sink(sink).with_schema(loza.FlatSchema()).with_redactor(loza.RedactKeys("password")) ) logger = loza.New(cfg) ctx = logger.start_event(loza.Params(event="checkout.run")) diff --git a/sdks/py/tests/test_canonical_fields.py b/sdks/py/tests/test_canonical_fields.py index 8685dbd..907bff3 100644 --- a/sdks/py/tests/test_canonical_fields.py +++ b/sdks/py/tests/test_canonical_fields.py @@ -31,8 +31,7 @@ def test_schema_version_is_protected() -> None: payload2 = json.loads(logger2.emit(ctx2)) # Canonical should remain v1, not override to v999 - assert payload2["schema_version"] == "v1", \ - "schema_version should be protected, not overridable by user" + assert payload2["schema_version"] == "v1", "schema_version should be protected, not overridable by user" def test_event_version_is_protected() -> None: @@ -51,8 +50,7 @@ def test_event_version_is_protected() -> None: logger2.finish(ctx2, "success") payload2 = json.loads(logger2.emit(ctx2)) - assert payload2["event_version"] == "v1", \ - "event_version should be protected, not overridable by user" + assert payload2["event_version"] == "v1", "event_version should be protected, not overridable by user" def test_event_id_is_protected() -> None: @@ -66,8 +64,7 @@ def test_event_id_is_protected() -> None: logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload["event_id"] == original_id, \ - "event_id should be protected, not overridable by user" + assert payload["event_id"] == original_id, "event_id should be protected, not overridable by user" assert payload["event_id"] != "user_attempted_override" @@ -87,8 +84,7 @@ def test_timestamp_is_protected() -> None: logger2.finish(ctx2, "success") payload2 = json.loads(logger2.emit(ctx2)) - assert payload2["timestamp"] != "1970-01-01T00:00:00Z", \ - "timestamp should be protected, not overridable by user" + assert payload2["timestamp"] != "1970-01-01T00:00:00Z", "timestamp should be protected, not overridable by user" def test_service_is_protected() -> None: @@ -101,8 +97,7 @@ def test_service_is_protected() -> None: logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload["service"] == "my-service", \ - "service should be protected, not overridable by user" + assert payload["service"] == "my-service", "service should be protected, not overridable by user" assert payload["service"] != "attacker-service" @@ -116,8 +111,7 @@ def test_event_name_is_protected() -> None: logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload["event"] == "checkout.completed", \ - "event name should be protected, not overridable by user" + assert payload["event"] == "checkout.completed", "event name should be protected, not overridable by user" assert payload["event"] != "fake.event" @@ -140,8 +134,7 @@ def test_duration_ms_is_protected() -> None: payload2 = json.loads(logger2.emit(ctx2)) # Should use actual measured duration, not user-provided value - assert payload2["duration_ms"] != 9999999, \ - "duration_ms should be calculated, not overridable by user" + assert payload2["duration_ms"] != 9999999, "duration_ms should be calculated, not overridable by user" def test_outcome_is_protected() -> None: @@ -161,19 +154,15 @@ def test_outcome_is_protected() -> None: payload2 = json.loads(logger2.emit(ctx2)) # Outcome should be "error" (set by finish), not "fake_outcome" - assert payload2["outcome"] == "error", \ - "outcome should be determined by finish() call, not user enrichment" + assert payload2["outcome"] == "error", "outcome should be determined by finish() call, not user enrichment" def test_trace_context_is_protected() -> None: """Verify trace context fields are protected if set.""" logger = loza.New(loza.Test("test")) - ctx = logger.start_event(loza.Params( - event="test.event", - trace_id="trace_123", - span_id="span_456", - request_id="req_789" - )) + ctx = logger.start_event( + loza.Params(event="test.event", trace_id="trace_123", span_id="span_456", request_id="req_789") + ) logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) @@ -185,37 +174,29 @@ def test_trace_context_is_protected() -> None: # Attempt to override trace context - enriched values should not override logger2 = loza.New(loza.Test("test")) - ctx2 = logger2.start_event(loza.Params( - event="test.event", - trace_id="trace_123", - span_id="span_456", - request_id="req_789" - )) + ctx2 = logger2.start_event( + loza.Params(event="test.event", trace_id="trace_123", span_id="span_456", request_id="req_789") + ) logger2.enrich( ctx2, loza.String("trace_id", "fake_trace"), loza.String("span_id", "fake_span"), - loza.String("request_id", "fake_request") + loza.String("request_id", "fake_request"), ) logger2.finish(ctx2, "success") payload2 = json.loads(logger2.emit(ctx2)) # Original values should be preserved - assert payload2.get("trace_id") == "trace_123", \ - "trace_id should be protected from user override" - assert payload2.get("span_id") == "span_456", \ - "span_id should be protected from user override" - assert payload2.get("request_id") == "req_789", \ - "request_id should be protected from user override" + assert payload2.get("trace_id") == "trace_123", "trace_id should be protected from user override" + assert payload2.get("span_id") == "span_456", "span_id should be protected from user override" + assert payload2.get("request_id") == "req_789", "request_id should be protected from user override" def test_canonical_fields_with_all_policies() -> None: """Verify canonical protection works with all duplicate policies.""" for policy in [loza.CanonicalWins, loza.UserWins, loza.KeepBoth]: - logger = loza.New( - loza.Test("test").with_duplicate_policy(policy) - ) + logger = loza.New(loza.Test("test").with_duplicate_policy(policy)) ctx = logger.start_event(loza.Params(event="policy_test")) original_id = ctx.event_id @@ -225,5 +206,4 @@ def test_canonical_fields_with_all_policies() -> None: logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload["event_id"] == original_id, \ - f"event_id should be protected even with {policy.__name__} policy" + assert payload["event_id"] == original_id, f"event_id should be protected even with {policy.__name__} policy" diff --git a/sdks/py/tests/test_collector_ack_fixture.py b/sdks/py/tests/test_collector_ack_fixture.py index 97a848e..f1b424a 100644 --- a/sdks/py/tests/test_collector_ack_fixture.py +++ b/sdks/py/tests/test_collector_ack_fixture.py @@ -45,10 +45,14 @@ def test_collector_ack_behavior_fixtures() -> None: try: sink = HTTPBatchSink(url, service="checkout") if fixture["expected"]["outcome"] == "success": - sink.write('{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}') + sink.write( + '{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}' + ) else: with pytest.raises(RuntimeError, match=fixture["expected"].get("message_contains", "")): - sink.write('{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}') + sink.write( + '{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}' + ) finally: server.shutdown() server.server_close() diff --git a/sdks/py/tests/test_collector_endpoint_config.py b/sdks/py/tests/test_collector_endpoint_config.py index acec3cf..6f4ba94 100644 --- a/sdks/py/tests/test_collector_endpoint_config.py +++ b/sdks/py/tests/test_collector_endpoint_config.py @@ -13,3 +13,43 @@ def test_collector_base_url_targets_ingest_route(logger_type): sink = next(sink for sink in logger._config.sinks if isinstance(sink, HTTPBatchSink)) assert sink.endpoint == "https://collector.example/events" + + +@pytest.mark.parametrize( + ("dsn", "username", "password", "collector"), + [ + ( + "loza://private-user:private-secret@collector.example/private-collector", + "private-user", + "private-secret", + "private-collector", + ), + ( + "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example/public-collector", + "lx_pub_6DJvd3D0izOaQx3n5BhKqN", + "", + "public-collector", + ), + ], +) +def test_credentialed_dsn_installs_scoped_credential_free_endpoint(dsn, username, password, collector): + logger = ClientLogger(Config.production("checkout").with_collector_endpoint(dsn)) + + sink = next(sink for sink in logger._config.sinks if isinstance(sink, HTTPBatchSink)) + + assert logger._config.collector_name == collector + assert sink.endpoint == f"https://collector.example:443/collectors/{collector}/events" + assert sink.username == username + assert sink.password == password + assert username not in sink.endpoint + + +def test_api_key_precedes_public_basic_auth() -> None: + sink = HTTPBatchSink( + "https://collector.example/collectors/public-collector/events", + api_key="api-key", + username="lx_pub_6DJvd3D0izOaQx3n5BhKqN", + password="", + ) + + assert sink._auth_headers() == {"Authorization": "Bearer api-key"} diff --git a/sdks/py/tests/test_config_loader.py b/sdks/py/tests/test_config_loader.py index 1a25578..76d1a65 100644 --- a/sdks/py/tests/test_config_loader.py +++ b/sdks/py/tests/test_config_loader.py @@ -1,4 +1,3 @@ - from loza.core.config import load_layered_config diff --git a/sdks/py/tests/test_delivery_semantics.py b/sdks/py/tests/test_delivery_semantics.py index 17b9840..9e51287 100644 --- a/sdks/py/tests/test_delivery_semantics.py +++ b/sdks/py/tests/test_delivery_semantics.py @@ -46,21 +46,17 @@ def test_multiple_events_have_unique_ids() -> None: event_ids.append(event["event_id"]) # All event_ids should be unique - assert len(event_ids) == len(set(event_ids)), \ - "Each event must have unique ID" + assert len(event_ids) == len(set(event_ids)), "Each event must have unique ID" # Sink should have all events - assert len(sink.events) == 5, \ - f"Expected 5 events in sink, got {len(sink.events)}" + assert len(sink.events) == 5, f"Expected 5 events in sink, got {len(sink.events)}" def test_sampling_respected_in_sink() -> None: """Verify sampler filters events from sink.""" sink = loza.MemorySink() logger = loza.New( - loza.Test("test") - .with_sink(sink) - .with_sampler(loza.SampleNone()) # 0% sample rate + loza.Test("test").with_sink(sink).with_sampler(loza.SampleNone()) # 0% sample rate ) ctx = logger.start_event(loza.Params(event="sampled_out")) @@ -70,8 +66,7 @@ def test_sampling_respected_in_sink() -> None: # Sampled-out event should return empty string assert payload == "", "Sampled-out events should not be emitted" # Sink should not receive sampled-out events - assert len(sink.events) == 0, \ - "Sampled-out events should not reach sink" + assert len(sink.events) == 0, "Sampled-out events should not reach sink" def test_error_events_recorded_in_sink() -> None: @@ -87,8 +82,7 @@ def test_error_events_recorded_in_sink() -> None: # Parse emitted payload emitted = json.loads(payload) - assert emitted.get("outcome") == "error", \ - "Error event should have outcome=error" + assert emitted.get("outcome") == "error", "Error event should have outcome=error" # Verify sink has the error event assert len(sink.events) == 1, "Error event should be in sink" @@ -138,8 +132,7 @@ def test_events_ordered_in_sink() -> None: # Sink events are stored as JSON strings, parse them sink_event_ids = [json.loads(e)["event_id"] for e in sink.events] - assert sink_event_ids == event_ids, \ - "Sink events should match emission order" + assert sink_event_ids == event_ids, "Sink events should match emission order" def test_idempotent_emit_returns_same_payload() -> None: @@ -155,8 +148,7 @@ def test_idempotent_emit_returns_same_payload() -> None: payload3 = logger.emit(ctx) # All should return the same payload - assert payload1 == payload2 == payload3, \ - "Repeated emit() calls should return same payload" + assert payload1 == payload2 == payload3, "Repeated emit() calls should return same payload" # Parse and verify event1 = json.loads(payload1) @@ -169,12 +161,9 @@ def test_delivery_preserves_event_integrity() -> None: sink = loza.MemorySink() logger = loza.New(loza.Test("checkout").with_sink(sink)) - ctx = logger.start_event(loza.Params( - event="purchase.completed", - request_id="req_123", - trace_id="trace_456", - user_id="user_789" - )) + ctx = logger.start_event( + loza.Params(event="purchase.completed", request_id="req_123", trace_id="trace_456", user_id="user_789") + ) logger.enrich(ctx, loza.String("order_id", "ord_001")) logger.enrich(ctx, loza.Int("amount_cents", 9999)) logger.finish(ctx, "success") @@ -207,8 +196,7 @@ def test_large_event_still_delivered() -> None: assert payload, "Large event should be emitted" event = json.loads(payload) - assert len(event.get("attrs", {})) >= 100, \ - "Large event should preserve all attributes" + assert len(event.get("attrs", {})) >= 100, "Large event should preserve all attributes" def test_concurrent_loggers_dont_interfere() -> None: diff --git a/sdks/py/tests/test_dsn.py b/sdks/py/tests/test_dsn.py index f9edd59..b5d1a24 100644 --- a/sdks/py/tests/test_dsn.py +++ b/sdks/py/tests/test_dsn.py @@ -10,9 +10,7 @@ from loza.core.dsn import LozaDSN, parse -_TEST_CASES_PATH = ( - Path(__file__).resolve().parents[3] / "spec" / "dsn" / "test-cases.json" -) +_TEST_CASES_PATH = Path(__file__).resolve().parents[3] / "spec" / "dsn" / "test-cases.json" def _load_cases(): @@ -38,6 +36,9 @@ def test_valid_dsn(case): assert dsn.host == expected["host"] assert dsn.port == expected["port"] assert dsn.project == expected["project"] + if "collectorName" in expected: + assert dsn.collector_name == expected["collectorName"] + assert dsn.project == dsn.collector_name assert dsn.env == expected.get("env", "default") assert dsn.service == expected.get("service", "") assert dsn.tls == expected["tls"] @@ -63,3 +64,24 @@ def test_valid_dsn(case): def test_invalid_dsn(case): with pytest.raises(ValueError): parse(case["input"]) + + +def test_dsn_repr_redacts_credentials() -> None: + dsn = parse("loza://key-id:s%40cret%3Avalue@example.com/project") + rendered = repr(dsn) + assert "s@cret:value" not in rendered + assert "credentials=''" in rendered + + +def test_public_dsn_routes_and_redacts_bearer_capability() -> None: + capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN" + dsn = parse(f"loza://{capability}:@example.com/public-collector") + + assert dsn.username == capability + assert dsn.password == "" + assert dsn.collector_name == "public-collector" + assert dsn.events_url == "https://example.com:443/collectors/public-collector/events" + assert dsn.batch_url == "https://example.com:443/collectors/public-collector/events/batch" + assert dsn.otlp_url == "https://example.com:443/collectors/public-collector/otlp/logs" + assert dsn.tail_ws_url == "wss://example.com:443/collectors/public-collector/tail" + assert capability not in repr(dsn) diff --git a/sdks/py/tests/test_e2e_live.py b/sdks/py/tests/test_e2e_live.py index dcbe22d..72c4b63 100644 --- a/sdks/py/tests/test_e2e_live.py +++ b/sdks/py/tests/test_e2e_live.py @@ -4,6 +4,7 @@ ``http://127.0.0.1:9308``), its ingest token in ``LOZA_API_KEY``, and an admin token in ``LOZA_TEST_COLLECTOR_ADMIN_KEY``. """ + import json import os import socket @@ -49,20 +50,18 @@ def test_e2e_collector_pipeline(): print("\n=== E2E Test: HTTPBatchSink -> loza-collector ===\n") # 1. Create logger with collector sink - config = ( - loza.Production("e2e-test-service") - .with_collector_endpoint(COLLECTOR_URL) - .with_api_key(INGEST_API_KEY) - ) + config = loza.Production("e2e-test-service").with_collector_endpoint(COLLECTOR_URL).with_api_key(INGEST_API_KEY) logger = loza.New(config) # 2. Emit 3 events for i in range(3): - ctx = logger.start_event(loza.Params( - event=f"e2e.test.event_{i}", - message=f"E2E test event {i}", - level="info", - )) + ctx = logger.start_event( + loza.Params( + event=f"e2e.test.event_{i}", + message=f"E2E test event {i}", + level="info", + ) + ) logger.enrich(ctx, loza.String("test_run", "e2e")) logger.enrich(ctx, loza.Int("sequence", i)) logger.finish(ctx, "success") @@ -99,7 +98,9 @@ def test_e2e_collector_pipeline(): try: query_req = urllib.request.Request( f"{COLLECTOR_URL}/query", - data=json.dumps({"sql": "SELECT event, service, outcome FROM events WHERE service = 'e2e-test-service' LIMIT 10"}).encode(), + data=json.dumps( + {"sql": "SELECT event, service, outcome FROM events WHERE service = 'e2e-test-service' LIMIT 10"} + ).encode(), headers={"Authorization": f"Bearer {ADMIN_API_KEY}", "content-type": "application/json"}, method="POST", ) @@ -114,6 +115,7 @@ def test_e2e_collector_pipeline(): # 5. CollectorClient health/ready/version/status from loza.core.http_client import CollectorClient + cc = CollectorClient(f"{COLLECTOR_URL}/events", api_key=ADMIN_API_KEY) assert cc.health() is True, "collector health check failed" diff --git a/sdks/py/tests/test_incident_id.py b/sdks/py/tests/test_incident_id.py index 66c0de7..3086825 100644 --- a/sdks/py/tests/test_incident_id.py +++ b/sdks/py/tests/test_incident_id.py @@ -10,17 +10,20 @@ def test_incident_id_in_params() -> None: """Verify incident_id set via Params appears in emitted output.""" logger = loza.New(loza.Test("test-svc")) - ctx = logger.start_event(loza.Params( - event="test.incident", - trace_id="trace-123", - span_id="span-456", - request_id="req-789", - incident_id="incident-test-001", - )) + ctx = logger.start_event( + loza.Params( + event="test.incident", + trace_id="trace-123", + span_id="span-456", + request_id="req-789", + incident_id="incident-test-001", + ) + ) logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload.get("incident_id") == "incident-test-001", \ + assert payload.get("incident_id") == "incident-test-001", ( f"expected incident_id=incident-test-001, got {payload.get('incident_id')}" + ) def test_incident_id_in_params_enrich() -> None: @@ -33,8 +36,9 @@ def test_incident_id_in_params_enrich() -> None: ctx = logger.start_event(params) logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload.get("incident_id") == "inc-enrich-test", \ + assert payload.get("incident_id") == "inc-enrich-test", ( f"expected incident_id=inc-enrich-test, got {payload.get('incident_id')}" + ) def test_incident_id_omitted_when_empty() -> None: @@ -43,21 +47,21 @@ def test_incident_id_omitted_when_empty() -> None: ctx = logger.start_event(loza.Params(event="test.no-incident")) logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert "incident_id" not in payload, \ - "incident_id should not be present in output when not set" + assert "incident_id" not in payload, "incident_id should not be present in output when not set" def test_incident_id_preserved_through_all_policies() -> None: """Verify incident_id survives all duplicate field policies.""" for policy in [loza.CanonicalWins, loza.UserWins, loza.KeepBoth]: - logger = loza.New( - loza.Test("test-svc").with_duplicate_policy(policy) + logger = loza.New(loza.Test("test-svc").with_duplicate_policy(policy)) + ctx = logger.start_event( + loza.Params( + event="test.incident-policy", + incident_id="inc-policy-test", + ) ) - ctx = logger.start_event(loza.Params( - event="test.incident-policy", - incident_id="inc-policy-test", - )) logger.finish(ctx, "success") payload = json.loads(logger.emit(ctx)) - assert payload.get("incident_id") == "inc-policy-test", \ + assert payload.get("incident_id") == "inc-policy-test", ( f"expected incident_id=inc-policy-test with {policy.__name__}, got {payload.get('incident_id')}" + ) diff --git a/sdks/py/tests/test_integration.py b/sdks/py/tests/test_integration.py index 75e50de..e8a7c3d 100644 --- a/sdks/py/tests/test_integration.py +++ b/sdks/py/tests/test_integration.py @@ -1,4 +1,5 @@ """Integration tests for Python SDK with collector.""" + import json import time import loza @@ -23,11 +24,7 @@ def test_event_integrity_through_pipeline() -> None: logger = loza.New(loza.Test("test_service")) # Create event with specific values - ctx = logger.start_event(loza.Params( - event="integrity_test", - message="Test message", - level="info" - )) + ctx = logger.start_event(loza.Params(event="integrity_test", message="Test message", level="info")) logger.enrich(ctx, loza.String("user_id", "user123")) logger.finish(ctx, "success") payload = logger.emit(ctx) @@ -64,10 +61,7 @@ def test_error_event_collection() -> None: """Verify error events are properly collected.""" logger = loza.New(loza.Test("error_test")) - ctx = logger.start_event(loza.Params( - event="error_event", - message="Something went wrong" - )) + ctx = logger.start_event(loza.Params(event="error_event", message="Something went wrong")) logger.enrich(ctx, loza.String("error_code", "E001")) logger.finish(ctx, "error") payload = logger.emit(ctx) @@ -127,9 +121,7 @@ def test_canonical_fields_immutable() -> None: def test_sampling_in_pipeline() -> None: """Verify sampling decision is consistent through pipeline.""" # Create logger with deterministic sampling - logger = loza.New( - loza.Test("sample_test").with_sampler(loza.SampleAll()) - ) + logger = loza.New(loza.Test("sample_test").with_sampler(loza.SampleAll())) for i in range(3): ctx = logger.start_event(loza.Params(event=f"sampled_{i}")) diff --git a/sdks/py/tests/test_metrics_export.py b/sdks/py/tests/test_metrics_export.py index d0555b6..7629cf0 100644 --- a/sdks/py/tests/test_metrics_export.py +++ b/sdks/py/tests/test_metrics_export.py @@ -1,4 +1,5 @@ """Test metrics export from Python SDK (Prometheus format).""" + import json import loza @@ -29,13 +30,17 @@ def test_events_emitted_total() -> None: def test_delivery_attempts_recorded() -> None: """Verify delivery attempts are recorded.""" + class CountingSink: def __init__(self): self.write_count = 0 + def write(self, payload: str) -> None: self.write_count += 1 + def flush(self) -> None: pass + def close(self) -> None: pass @@ -53,9 +58,7 @@ def close(self) -> None: def test_sampling_rate_gauge() -> None: """Verify sampling rate is tracked.""" # Create logger with 50% sampling - logger = loza.New( - loza.Test("test").with_sampler(loza.SampleRandom(0.5)) - ) + logger = loza.New(loza.Test("test").with_sampler(loza.SampleRandom(0.5))) # Emit multiple events and count how many are sampled sampled_count = 0 @@ -78,12 +81,15 @@ def test_sink_latency_recorded() -> None: class SlowSink: def __init__(self): self.latencies = [] + def write(self, payload: str) -> None: start = time.time() time.sleep(0.001) # 1ms simulated latency self.latencies.append(time.time() - start) + def flush(self) -> None: pass + def close(self) -> None: pass @@ -194,9 +200,7 @@ def emit_event(i): def test_dropped_event_count() -> None: """Verify dropped events are counted.""" # Create logger with none sampler (0% sampling) - logger = loza.New( - loza.Test("test").with_sampler(loza.SampleNone()) - ) + logger = loza.New(loza.Test("test").with_sampler(loza.SampleNone())) dropped_count = 0 for i in range(10): diff --git a/sdks/py/tests/test_panic_safety.py b/sdks/py/tests/test_panic_safety.py index 694a573..9ef81b7 100644 --- a/sdks/py/tests/test_panic_safety.py +++ b/sdks/py/tests/test_panic_safety.py @@ -54,11 +54,14 @@ def test_invalid_event_name_handled() -> None: def test_sink_write_failure_doesnt_crash() -> None: """Verify sink failures don't crash the SDK.""" + class FailingSink: def write(self, payload: str) -> None: raise IOError("Sink is broken") + def flush(self) -> None: pass + def close(self) -> None: pass @@ -72,17 +75,14 @@ def close(self) -> None: # Event may still emit to caller even if sink fails except Exception as e: # Should be a known error, not a panic - assert isinstance(e, (IOError, OSError, RuntimeError)), \ - f"Unexpected error type: {type(e).__name__}" + assert isinstance(e, (IOError, OSError, RuntimeError)), f"Unexpected error type: {type(e).__name__}" def test_invalid_duplicate_policy_rejected() -> None: """Verify invalid duplicate policies are rejected.""" try: # Try to create logger with None policy - logger = loza.New( - loza.Test("test").with_duplicate_policy(None) - ) + logger = loza.New(loza.Test("test").with_duplicate_policy(None)) # If it doesn't raise, continue ctx = logger.start_event(loza.Params(event="test")) logger.finish(ctx, "success") @@ -95,9 +95,7 @@ def test_invalid_sampler_rejected() -> None: """Verify invalid samplers are rejected.""" try: # Try to create logger with None sampler - logger = loza.New( - loza.Test("test").with_sampler(None) - ) + logger = loza.New(loza.Test("test").with_sampler(None)) ctx = logger.start_event(loza.Params(event="test")) logger.finish(ctx, "success") except (TypeError, ValueError, AttributeError): @@ -108,9 +106,7 @@ def test_invalid_sampler_rejected() -> None: def test_invalid_schema_rejected() -> None: """Verify invalid schemas are rejected.""" try: - logger = loza.New( - loza.Test("test").with_schema(None) - ) + logger = loza.New(loza.Test("test").with_schema(None)) ctx = logger.start_event(loza.Params(event="test")) logger.finish(ctx, "success") except (TypeError, ValueError, AttributeError): @@ -121,9 +117,7 @@ def test_invalid_schema_rejected() -> None: def test_invalid_redactor_rejected() -> None: """Verify invalid redactors are rejected.""" try: - logger = loza.New( - loza.Test("test").with_redactor(None) - ) + logger = loza.New(loza.Test("test").with_redactor(None)) ctx = logger.start_event(loza.Params(event="test")) logger.finish(ctx, "success") except (TypeError, ValueError, AttributeError): @@ -143,8 +137,9 @@ def test_finish_twice_handled() -> None: logger.finish(ctx, "error") except Exception as e: # Should be a known error type (EventAlreadyFinishedError is OK) - assert "already finished" in str(e) or isinstance(e, (ValueError, RuntimeError)), \ + assert "already finished" in str(e) or isinstance(e, (ValueError, RuntimeError)), ( f"Unexpected error: {type(e).__name__}" + ) def test_emit_before_finish_handled() -> None: @@ -188,7 +183,7 @@ def test_special_characters_in_attributes() -> None: special_values = [ "\x00null byte", "\uffff unicode", - "\\\"quotes\\\"", + '\\"quotes\\"', "\\nnewlines\\n", "{}[]()\"quotes'", ] diff --git a/sdks/py/tests/test_spec_conformance.py b/sdks/py/tests/test_spec_conformance.py index 6d2123a..4573f1e 100644 --- a/sdks/py/tests/test_spec_conformance.py +++ b/sdks/py/tests/test_spec_conformance.py @@ -60,7 +60,18 @@ def test_invalid_golden_fixture_examples_stay_invalid() -> None: assert "event_id" not in missing_event_id invalid_enums = _load_payload(invalid_files["invalid_enum_values.json"]) - assert invalid_enums["kind"] not in {"event", "http", "job", "queue", "cli", "cron", "log", "checkpoint", "agent", "ai"} + assert invalid_enums["kind"] not in { + "event", + "http", + "job", + "queue", + "cli", + "cron", + "log", + "checkpoint", + "agent", + "ai", + } missing_versions = _load_payload(invalid_files["missing_versions.json"]) assert "schema_version" not in missing_versions or "event_version" not in missing_versions diff --git a/sdks/py/tests/test_timing.py b/sdks/py/tests/test_timing.py index efb5c5c..3d8f4fc 100644 --- a/sdks/py/tests/test_timing.py +++ b/sdks/py/tests/test_timing.py @@ -1,4 +1,5 @@ """Tests for timing primitives: Process, Timer, Group, Stopwatch.""" + import time import loza diff --git a/sdks/rs/Cargo.lock b/sdks/rs/Cargo.lock index 0161963..783cc22 100644 --- a/sdks/rs/Cargo.lock +++ b/sdks/rs/Cargo.lock @@ -1042,10 +1042,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loza" -version = "0.2.6" +version = "0.3.0" dependencies = [ "actix-web", "axum", + "base64", "fastrand", "flate2", "futures-util", diff --git a/sdks/rs/Cargo.toml b/sdks/rs/Cargo.toml index c6e5ede..3dc6f07 100644 --- a/sdks/rs/Cargo.toml +++ b/sdks/rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loza" -version = "0.2.6" +version = "0.3.0" edition = "2021" description = "Rust LOZA wide-event SDK" license = "MIT" @@ -17,6 +17,7 @@ flate2 = "1" ureq = { version = "2", features = ["gzip", "json"] } regex = "1" sha2 = "0.10" +base64 = "0.22" hostname = "0.4" futures-util = { version = "0.3", optional = true } actix-web = { version = "4", optional = true } diff --git a/sdks/rs/README.md b/sdks/rs/README.md index a4db58f..9eebbaa 100644 --- a/sdks/rs/README.md +++ b/sdks/rs/README.md @@ -1,6 +1,6 @@ # LOZA-RS -**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance +**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance `loza-rs` is a collector-first Rust SDK for wide events. It builds one structured event per operation (request, job, queue message, CLI run, cron run), then emits to your log/analytics backend. diff --git a/sdks/rs/docs/sdk-parity-manifest.json b/sdks/rs/docs/sdk-parity-manifest.json index b0da7b2..3cfee69 100644 --- a/sdks/rs/docs/sdk-parity-manifest.json +++ b/sdks/rs/docs/sdk-parity-manifest.json @@ -1,8 +1,8 @@ { - "version": "0.2.6", + "version": "0.3.0", "package": "loza", "scope": "lightweight-sdk", - "stability": "product-parity-v0.2.6", + "stability": "product-parity-v0.3.0", "policy": "full-product-method-family", "sdks": ["go", "python", "rust", "javascript"], "lifecycle": [ diff --git a/sdks/rs/loza-rs.yaml b/sdks/rs/loza-rs.yaml index 3774ea8..20476e5 100644 --- a/sdks/rs/loza-rs.yaml +++ b/sdks/rs/loza-rs.yaml @@ -1,7 +1,7 @@ name: loza-rs kind: sdk language: rust -version: 0.2.6 +version: 0.3.0 description: Rust SDK for LOZA license: MIT repository: https://github.com/Astraive/loza diff --git a/sdks/rs/src/config/config.rs b/sdks/rs/src/config/config.rs index 400b5ed..c9c6afb 100644 --- a/sdks/rs/src/config/config.rs +++ b/sdks/rs/src/config/config.rs @@ -40,7 +40,11 @@ pub struct Config { pub strict: bool, pub async_enabled: bool, pub collector_endpoint: String, + pub collector_name: String, pub api_key: String, + pub basic_username: Option, + pub basic_password: Option, + pub insecure: bool, pub duplicate_policy: String, pub max_event_bytes: usize, pub sampler: SamplerConfig, @@ -63,11 +67,19 @@ impl std::fmt::Debug for Config { .field("alias", &self.alias) .field("version", &self.version) .field("environment", &self.environment) - .field("region", &self.region) .field("level", &self.level) .field("strict", &self.strict) - .field("async_enabled", &self.async_enabled) .field("collector_endpoint", &self.collector_endpoint) + .field("collector_name", &self.collector_name) + .field( + "api_key", + &(!self.api_key.is_empty()).then_some(""), + ) + .field( + "basic_credentials", + &self.basic_username.as_ref().map(|_| ""), + ) + .field("insecure", &self.insecure) .field("duplicate_policy", &self.duplicate_policy) .field("max_event_bytes", &self.max_event_bytes) .field("sinks", &self.sinks) @@ -83,7 +95,6 @@ impl std::fmt::Debug for Config { .finish() } } - #[derive(Clone, Debug, Default, Deserialize)] pub(crate) struct FileConfig { pub(crate) service: Option, @@ -94,7 +105,11 @@ pub(crate) struct FileConfig { pub(crate) strict: Option, pub(crate) async_enabled: Option, pub(crate) collector_endpoint: Option, + pub(crate) collector_name: Option, pub(crate) api_key: Option, + pub(crate) basic_username: Option, + pub(crate) basic_password: Option, + pub(crate) insecure: Option, pub(crate) duplicate_policy: Option, pub(crate) max_event_bytes: Option, } @@ -122,7 +137,7 @@ impl MemorySinkStore { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub enum SinkConfig { Stdout, Stderr, @@ -132,6 +147,9 @@ pub enum SinkConfig { HttpBatch { endpoint: String, api_key: Option, + basic_username: Option, + basic_password: Option, + insecure: bool, timeout_ms: u64, max_batch_bytes: usize, max_retries: u32, @@ -140,6 +158,44 @@ pub enum SinkConfig { }, } +impl std::fmt::Debug for SinkConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdout => f.write_str("Stdout"), + Self::Stderr => f.write_str("Stderr"), + Self::File(path) => f.debug_tuple("File").field(path).finish(), + Self::Memory(store) => f.debug_tuple("Memory").field(store).finish(), + Self::Noop => f.write_str("Noop"), + Self::HttpBatch { + endpoint, + api_key, + basic_username, + basic_password: _basic_password, + insecure, + timeout_ms, + max_batch_bytes, + max_retries, + enable_compression, + ndjson, + } => f + .debug_struct("HttpBatch") + .field("endpoint", endpoint) + .field("api_key", &api_key.as_ref().map(|_| "")) + .field( + "basic_credentials", + &basic_username.as_ref().map(|_| ""), + ) + .field("insecure", insecure) + .field("timeout_ms", timeout_ms) + .field("max_batch_bytes", max_batch_bytes) + .field("max_retries", max_retries) + .field("enable_compression", enable_compression) + .field("ndjson", ndjson) + .finish(), + } + } +} + #[derive(Clone)] pub enum SamplerConfig { All, @@ -273,8 +329,19 @@ impl Config { self } - pub fn with_collector_endpoint(mut self, endpoint: impl Into) -> Self { - self.collector_endpoint = endpoint.into(); + pub fn with_collector_endpoint(self, endpoint: impl Into) -> Self { + let endpoint = endpoint.into(); + if endpoint.starts_with("loza://") { + self.with_dsn(endpoint) + } else { + let mut config = self; + config.collector_endpoint = endpoint; + config + } + } + + pub fn with_collector_name(mut self, collector_name: impl Into) -> Self { + self.collector_name = collector_name.into(); self } @@ -282,6 +349,37 @@ impl Config { self.api_key = api_key.into(); self } + pub fn with_basic_auth( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.basic_username = Some(username.into()); + self.basic_password = Some(password.into()); + self + } + + pub fn with_dsn(mut self, raw: impl AsRef) -> Self { + match super::dsn::parse(raw.as_ref()) { + Ok(dsn) => { + self.collector_endpoint = dsn.base_url; + self.collector_name = dsn.collector_name; + self.environment = dsn.env; + if !dsn.service.is_empty() { + self.service = dsn.service; + } + if dsn.username.is_some() && dsn.password.is_some() { + self.basic_username = dsn.username; + self.basic_password = dsn.password; + } + self.insecure = !dsn.tls; + } + Err(_) => { + self.collector_endpoint = raw.as_ref().to_string(); + } + } + self + } pub fn with_duplicate_policy(mut self, policy: impl Into) -> Self { self.duplicate_policy = policy.into(); @@ -419,6 +517,37 @@ impl Config { ), )); } + if self.basic_username.is_some() != self.basic_password.is_some() { + return Err(crate::errors::LozaError::Validation( + crate::errors::ValidationError::new( + None, + "invalid_basic_auth", + "basic username and password must be configured together".to_string(), + ), + )); + } + if self.basic_username.is_some() + && self.collector_endpoint.starts_with("http://") + && (!self.insecure || !is_local_endpoint(&self.collector_endpoint)) + { + return Err(crate::errors::LozaError::Validation( + crate::errors::ValidationError::new( + Some("collector_endpoint"), + "insecure_basic_auth", + "credentialed HTTP requires an explicit local/insecure configuration" + .to_string(), + ), + )); + } + if self.collector_endpoint.starts_with("loza://") { + return Err(crate::errors::LozaError::Validation( + crate::errors::ValidationError::new( + Some("collector_endpoint"), + "invalid_endpoint", + "loza:// DSNs must be resolved before creating a client".to_string(), + ), + )); + } Ok(()) } } @@ -447,11 +576,27 @@ impl FileConfig { cfg.async_enabled = value; } if let Some(value) = self.collector_endpoint { - cfg.collector_endpoint = value; + if value.starts_with("loza://") { + cfg = cfg.with_dsn(value); + } else { + cfg.collector_endpoint = value; + } + } + if let Some(value) = self.collector_name { + cfg.collector_name = value; } if let Some(value) = self.api_key { cfg.api_key = value; } + if let Some(value) = self.basic_username { + cfg.basic_username = Some(value); + } + if let Some(value) = self.basic_password { + cfg.basic_password = Some(value); + } + if let Some(value) = self.insecure { + cfg.insecure = value; + } if let Some(value) = self.duplicate_policy { cfg.duplicate_policy = value; } @@ -495,7 +640,7 @@ pub fn new_client(code_config: Config) -> Result Result Result Config { - // Compare against base defaults to determine what was explicitly set let defaults = Config::base(); - if code.service != defaults.service { base.service = code.service; } @@ -553,16 +699,25 @@ fn merge_code_config(mut base: Config, code: Config) -> Config { if code.collector_endpoint != defaults.collector_endpoint { base.collector_endpoint = code.collector_endpoint; } + if code.collector_name != defaults.collector_name { + base.collector_name = code.collector_name; + } if code.api_key != defaults.api_key { base.api_key = code.api_key; } + if code.basic_username.is_some() { + base.basic_username = code.basic_username; + base.basic_password = code.basic_password; + } + if code.insecure != defaults.insecure { + base.insecure = code.insecure; + } if code.duplicate_policy != defaults.duplicate_policy { base.duplicate_policy = code.duplicate_policy; } if code.max_event_bytes != defaults.max_event_bytes { base.max_event_bytes = code.max_event_bytes; } - // For sinks, if code config has non-default sinks, use them if !(code.sinks.is_empty() || code.sinks.len() == 1 && matches!(&code.sinks[0], SinkConfig::Stdout)) { @@ -608,9 +763,21 @@ fn overlay_file_config(mut base: FileConfig, override_cfg: FileConfig) -> FileCo if override_cfg.collector_endpoint.is_some() { base.collector_endpoint = override_cfg.collector_endpoint; } + if override_cfg.collector_name.is_some() { + base.collector_name = override_cfg.collector_name; + } if override_cfg.api_key.is_some() { base.api_key = override_cfg.api_key; } + if override_cfg.basic_username.is_some() { + base.basic_username = override_cfg.basic_username; + } + if override_cfg.basic_password.is_some() { + base.basic_password = override_cfg.basic_password; + } + if override_cfg.insecure.is_some() { + base.insecure = override_cfg.insecure; + } if override_cfg.duplicate_policy.is_some() { base.duplicate_policy = override_cfg.duplicate_policy; } @@ -620,6 +787,40 @@ fn overlay_file_config(mut base: FileConfig, override_cfg: FileConfig) -> FileCo base } +fn is_local_endpoint(endpoint: &str) -> bool { + let authority = endpoint + .strip_prefix("http://") + .or_else(|| endpoint.strip_prefix("https://")) + .unwrap_or(endpoint) + .split('/') + .next() + .unwrap_or_default() + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or_else(|| { + endpoint + .strip_prefix("http://") + .or_else(|| endpoint.strip_prefix("https://")) + .unwrap_or(endpoint) + .split('/') + .next() + .unwrap_or_default() + }); + let host = if let Some(ipv6) = authority.strip_prefix('[') { + ipv6.split(']').next().unwrap_or_default() + } else { + authority.split(':').next().unwrap_or_default() + }; + matches!(host, "localhost" | "127.0.0.1" | "::1") +} + +pub(crate) fn collector_events_endpoint(endpoint: &str, collector_name: &str) -> String { + let base = endpoint.trim().trim_end_matches('/'); + if collector_name.is_empty() { + return base.to_string(); + } + format!("{base}/collectors/{collector_name}/events") +} fn load_file_config(path: impl AsRef) -> Result { let raw = fs::read_to_string(path)?; let parsed = serde_yaml::from_str::(&raw).unwrap_or_default(); diff --git a/sdks/rs/src/config/defaults.rs b/sdks/rs/src/config/defaults.rs index fab0e2b..b0f8945 100644 --- a/sdks/rs/src/config/defaults.rs +++ b/sdks/rs/src/config/defaults.rs @@ -57,7 +57,11 @@ impl Config { strict: false, async_enabled: false, collector_endpoint: String::new(), + collector_name: String::new(), api_key: String::new(), + basic_username: None, + basic_password: None, + insecure: false, duplicate_policy: "canonical_wins".to_string(), max_event_bytes: 256 * 1024, sampler: SamplerConfig::All, diff --git a/sdks/rs/src/config/dsn.rs b/sdks/rs/src/config/dsn.rs index b28479e..eeebbd5 100644 --- a/sdks/rs/src/config/dsn.rs +++ b/sdks/rs/src/config/dsn.rs @@ -1,11 +1,12 @@ use std::fmt; /// Parsed and resolved loza:// DSN. -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub struct LozaDSN { pub scheme: String, pub host: String, pub port: u16, + pub collector_name: String, pub project: String, pub env: String, pub service: String, @@ -16,6 +17,36 @@ pub struct LozaDSN { pub batch_url: String, pub otlp_url: String, pub tail_ws_url: String, + pub username: Option, + pub password: Option, +} + +impl fmt::Debug for LozaDSN { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LozaDSN") + .field("scheme", &self.scheme) + .field("host", &self.host) + .field("port", &self.port) + .field("collector_name", &self.collector_name) + .field("project", &self.project) + .field("env", &self.env) + .field("service", &self.service) + .field("tls", &self.tls) + .field("transport", &self.transport) + .field("base_url", &self.base_url) + .field("events_url", &self.events_url) + .field("batch_url", &self.batch_url) + .field("otlp_url", &self.otlp_url) + .field("tail_ws_url", &self.tail_ws_url) + .field("credentials", &self.username.as_ref().map(|_| "")) + .finish() + } +} + +impl fmt::Display for LozaDSN { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.base_url) + } } #[derive(Debug, Clone)] @@ -34,8 +65,9 @@ impl std::error::Error for DsnError {} /// Validation rules: /// - Scheme must be `loza://` /// - Host is required -/// - Project path is required -/// - No userinfo allowed +/// - Collector path is required +/// - Private userinfo must contain a non-empty username and password +/// - Public lx_pub_... userinfo uses an explicitly empty password /// - tls must be "true", "false", or "auto" /// - transport must be "http", "otlp", or "grpc" /// - Port must be 1-65535 if specified @@ -65,17 +97,55 @@ pub fn parse(raw: &str) -> Result { None => (rest, ""), }; - // Reject userinfo (API keys must not be in the URL). - if authority.contains('@') { - return Err(DsnError( - "do not put API keys in the URL, use LOZA_API_KEY instead".into(), - )); - } + // Split optional userinfo from the authority. The final `@` is the + // delimiter; an additional raw `@` belongs to malformed userinfo. + let (userinfo, authority) = match authority.rsplit_once('@') { + Some((userinfo, authority)) => { + if userinfo.contains('@') { + return Err(DsnError("userinfo contains an unescaped @".into())); + } + (Some(userinfo), authority) + } + None => (None, authority), + }; + + let (username, password) = match userinfo { + None => (None, None), + Some(value) => { + let (raw_username, raw_password) = value + .split_once(':') + .ok_or_else(|| DsnError("userinfo must be username:password".into()))?; + if raw_username.is_empty() { + return Err(DsnError( + "userinfo requires username:password or lx_pub_...:".into(), + )); + } + let username = percent_decode_userinfo(raw_username, "username")?; + let password = percent_decode_userinfo(raw_password, "password")?; + if username.is_empty() + || (password.is_empty() && !is_public_credential_username(&username)) + { + return Err(DsnError( + "userinfo requires username:password or lx_pub_...:".into(), + )); + } + if username.contains(':') || username.chars().any(char::is_whitespace) { + return Err(DsnError( + "userinfo username must not contain ':' or whitespace".into(), + )); + } + (Some(username), Some(password)) + } + }; if authority.is_empty() { return Err(DsnError("host is required".into())); } + if authority.contains('@') { + return Err(DsnError("authority contains an unescaped @".into())); + } + // Parse host and port from authority. let (host, port_str) = if authority.starts_with('[') { // IPv6: [::1] or [::1]:port @@ -120,10 +190,10 @@ pub fn parse(raw: &str) -> Result { None => (path_query, ""), }; - let project = path_part.trim_start_matches('/'); - if project.is_empty() { + let collector_name = path_part.trim_start_matches('/'); + if collector_name.is_empty() { return Err(DsnError( - "project path is required, e.g. loza://host/my-project".into(), + "collector path is required, e.g. loza://host/my-collector".into(), )); } @@ -207,23 +277,83 @@ pub fn parse(raw: &str) -> Result { let base_url = format!("{scheme}://{host_part}:{port}"); + let collector_base_url = format!("{base_url}/collectors/{collector_name}"); + let collector_tail_base_url = + format!("{ws_scheme}://{host_part}:{port}/collectors/{collector_name}"); + Ok(LozaDSN { scheme: "loza".to_string(), host: host.to_string(), port, - project: project.to_string(), + collector_name: collector_name.to_string(), + project: collector_name.to_string(), env, service, tls, transport, base_url: base_url.clone(), - events_url: format!("{base_url}/events"), - batch_url: format!("{base_url}/events/batch"), - otlp_url: format!("{base_url}/otlp/logs"), - tail_ws_url: format!("{ws_scheme}://{host_part}:{port}/tail"), + events_url: format!("{collector_base_url}/events"), + batch_url: format!("{collector_base_url}/events/batch"), + otlp_url: format!("{collector_base_url}/otlp/logs"), + tail_ws_url: format!("{collector_tail_base_url}/tail"), + username, + password, }) } +fn percent_decode_userinfo(value: &str, field: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(DsnError(format!( + "userinfo {field} has an incomplete percent escape" + ))); + } + let high = hex_value(bytes[index + 1]).ok_or_else(|| { + DsnError(format!("userinfo {field} has an invalid percent escape")) + })?; + let low = hex_value(bytes[index + 2]).ok_or_else(|| { + DsnError(format!("userinfo {field} has an invalid percent escape")) + })?; + decoded.push((high << 4) | low); + index += 3; + } + byte if is_unreserved(byte) => { + decoded.push(byte); + index += 1; + } + _ => { + return Err(DsnError(format!( + "userinfo {field} contains a character that must be percent-encoded" + ))) + } + } + } + String::from_utf8(decoded).map_err(|_| DsnError(format!("userinfo {field} is not valid UTF-8"))) +} + +fn is_unreserved(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +pub fn is_public_credential_username(username: &str) -> bool { + const PREFIX: &str = "lx_pub_"; + username.starts_with(PREFIX) && username.len() > PREFIX.len() +} + fn is_localhost(host: &str) -> bool { host == "localhost" || host == "127.0.0.1" || host == "::1" } @@ -242,6 +372,24 @@ mod tests { assert_eq!(dsn.base_url, "http://localhost:9308"); } + #[test] + fn parses_and_redacts_credentials() { + let dsn = parse("loza://key%2Did:s%40cret%3Avalue@host/project").unwrap(); + assert_eq!(dsn.username.as_deref(), Some("key-id")); + assert_eq!(dsn.password.as_deref(), Some("s@cret:value")); + assert!(!dsn.base_url.contains("s@cret")); + assert!(!format!("{dsn:?}").contains("s@cret")); + } + + #[test] + fn rejects_invalid_credentials() { + assert!(parse("loza://key@host/project").is_err()); + assert!(parse("loza://key:@host/project").is_err()); + assert!(parse("loza://:secret@host/project").is_err()); + assert!(parse("loza://key:secret%ZZ@host/project").is_err()); + assert!(parse("loza://key%3Aname:secret@host/project").is_err()); + } + #[test] fn reject_empty() { assert!(parse("").is_err()); @@ -261,9 +409,4 @@ mod tests { fn reject_no_project() { assert!(parse("loza://host").is_err()); } - - #[test] - fn reject_userinfo() { - assert!(parse("loza://key@host/project").is_err()); - } } diff --git a/sdks/rs/src/config/env.rs b/sdks/rs/src/config/env.rs index 6ac5439..17716e7 100644 --- a/sdks/rs/src/config/env.rs +++ b/sdks/rs/src/config/env.rs @@ -1,17 +1,32 @@ use super::config::FileConfig; pub(crate) fn load_env_config() -> FileConfig { + let dsn = env_string("LOZA_DSN").and_then(|raw| super::dsn::parse(&raw).ok()); + let dsn_service = dsn + .as_ref() + .map(|value| value.service.clone()) + .filter(|value| !value.is_empty()); + let dsn_environment = dsn.as_ref().map(|value| value.env.clone()); + let dsn_collector_name = dsn.as_ref().map(|value| value.collector_name.clone()); + let dsn_username = dsn.as_ref().and_then(|value| value.username.clone()); + let dsn_password = dsn.as_ref().and_then(|value| value.password.clone()); + let dsn_insecure = dsn.as_ref().map(|value| !value.tls); FileConfig { - service: env_string("LOZA_SERVICE_NAME"), + service: env_string("LOZA_SERVICE_NAME").or(dsn_service), version: env_string("LOZA_SERVICE_VERSION"), - environment: env_string("LOZA_ENVIRONMENT"), + environment: env_string("LOZA_ENVIRONMENT").or(dsn_environment), region: env_string("LOZA_REGION"), level: env_string("LOZA_LOG_LEVEL"), strict: env_bool("LOZA_STRICT"), async_enabled: env_bool("LOZA_ASYNC_ENABLED"), - collector_endpoint: env_string("LOZA_COLLECTOR_ENDPOINT") - .or_else(|| env_string("LOZA_COLLECTOR_URL")), + collector_endpoint: env_string("LOZA_COLLECTOR_URL") + .or_else(|| env_string("LOZA_COLLECTOR_ENDPOINT")) + .or_else(|| dsn.as_ref().map(|value| value.base_url.clone())), + collector_name: dsn_collector_name, api_key: env_string("LOZA_API_KEY").or_else(|| env_string("LOZA_COLLECTOR_API_KEY")), + basic_username: dsn_username, + basic_password: dsn_password, + insecure: env_bool("LOZA_INSECURE").or(dsn_insecure), duplicate_policy: env_string("LOZA_DUPLICATE_POLICY"), max_event_bytes: env_usize("LOZA_MAX_EVENT_BYTES"), } diff --git a/sdks/rs/src/core/client.rs b/sdks/rs/src/core/client.rs index 7443886..82c0617 100644 --- a/sdks/rs/src/core/client.rs +++ b/sdks/rs/src/core/client.rs @@ -1,4 +1,6 @@ +use base64::Engine as _; use serde_json::Value; + use std::collections::BTreeMap; use std::io; use std::time::Duration; @@ -152,10 +154,13 @@ impl HTTPClient { } /// Collector-specific HTTP client with envelope support. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct CollectorHttpClient { pub endpoint: String, pub api_key: Option, + pub basic_username: Option, + pub basic_password: Option, + pub insecure: bool, pub auth_header: String, pub timeout_ms: u64, pub sdk_name: String, @@ -163,12 +168,34 @@ pub struct CollectorHttpClient { pub service: Option, } +impl std::fmt::Debug for CollectorHttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CollectorHttpClient") + .field("endpoint", &self.endpoint) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field( + "basic_credentials", + &self.basic_username.as_ref().map(|_| ""), + ) + .field("insecure", &self.insecure) + .field("auth_header", &self.auth_header) + .field("timeout_ms", &self.timeout_ms) + .field("sdk_name", &self.sdk_name) + .field("sdk_version", &self.sdk_version) + .field("service", &self.service) + .finish() + } +} + impl CollectorHttpClient { pub fn new(endpoint: impl Into) -> Self { let endpoint = normalize_collector_endpoint(endpoint.into()); Self { endpoint, api_key: None, + basic_username: None, + basic_password: None, + insecure: false, auth_header: "Authorization".to_string(), timeout_ms: 2_000, sdk_name: "loza-rs".to_string(), @@ -182,6 +209,21 @@ impl CollectorHttpClient { self } + pub fn with_basic_auth( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.basic_username = Some(username.into()); + self.basic_password = Some(password.into()); + self + } + + pub fn with_insecure(mut self, insecure: bool) -> Self { + self.insecure = insecure; + self + } + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { self.timeout_ms = timeout_ms; self @@ -235,6 +277,24 @@ impl CollectorHttpClient { if let Some(api_key) = &self.api_key { request = request.with_header(&self.auth_header, format!("Bearer {}", api_key)); + } else { + match (&self.basic_username, &self.basic_password) { + (Some(username), Some(password)) => { + validate_basic_auth_endpoint( + &self.endpoint, + username, + password, + self.insecure, + )?; + let token = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}")); + request = request.with_header(&self.auth_header, format!("Basic {token}")); + } + (None, None) => {} + _ => { + return Err("basic username and password must be configured together".into()); + } + } } request = request @@ -408,6 +468,38 @@ pub fn inject_http_headers(request: &mut HTTPRequest, event: &crate::EventContex } /// Extract trace context from HTTP headers. +fn validate_basic_auth_endpoint( + endpoint: &str, + username: &str, + _password: &str, + insecure: bool, +) -> Result<(), String> { + if username.is_empty() || username.contains(':') || username.chars().any(char::is_whitespace) { + return Err( + "basic auth username must be non-empty and contain no ':' or whitespace".into(), + ); + } + let Some((scheme, rest)) = endpoint.split_once("://") else { + return Ok(()); + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if authority.contains('@') { + return Err("collector credentials must not be embedded in the endpoint URL".into()); + } + if !scheme.eq_ignore_ascii_case("http") || insecure { + return Ok(()); + } + let host = if let Some(ipv6) = authority.strip_prefix('[') { + ipv6.split(']').next().unwrap_or(ipv6) + } else { + authority.split(':').next().unwrap_or(authority) + }; + if matches!(host, "localhost" | "127.0.0.1" | "::1") { + return Ok(()); + } + Err("credentialed HTTP requires TLS (set insecure explicitly for local development)".into()) +} + pub fn extract_http_headers( headers: &BTreeMap, ) -> (Option, Option, Option) { diff --git a/sdks/rs/src/core/options.rs b/sdks/rs/src/core/options.rs index 91060c1..51abbc1 100644 --- a/sdks/rs/src/core/options.rs +++ b/sdks/rs/src/core/options.rs @@ -48,6 +48,11 @@ pub fn with_collector_endpoint(endpoint: impl Into) -> ConfigOption { Box::new(move |cfg| cfg.with_collector_endpoint(endpoint)) } +pub fn with_collector_name(collector_name: impl Into) -> ConfigOption { + let collector_name = collector_name.into(); + Box::new(move |cfg| cfg.with_collector_name(collector_name)) +} + pub fn with_duplicate_policy(policy: impl Into) -> ConfigOption { let policy = policy.into(); Box::new(move |cfg| cfg.with_duplicate_policy(policy)) diff --git a/sdks/rs/src/core/version.rs b/sdks/rs/src/core/version.rs index 908151a..2c0f080 100644 --- a/sdks/rs/src/core/version.rs +++ b/sdks/rs/src/core/version.rs @@ -1,8 +1,7 @@ -/// Load SDK version from loza-rs.yaml metadata file. -/// -/// Falls back to a hardcoded default if the file cannot be found or parsed. - -const FALLBACK_VERSION: &str = "0.2.6"; +//! Load SDK version from loza-rs.yaml metadata file. +//! +//! Falls back to a hardcoded default if the file cannot be found or parsed. +const FALLBACK_VERSION: &str = "0.3.0"; /// Read version from loza-rs.yaml, searching standard locations. /// Returns FALLBACK_VERSION if file not found or parsing fails. @@ -36,5 +35,5 @@ pub fn sdk_version() -> &'static str { // Use a once_cell-like pattern for thread-safe lazy initialization use std::sync::OnceLock; static VERSION: OnceLock = OnceLock::new(); - VERSION.get_or_init(|| load_version()) + VERSION.get_or_init(load_version) } diff --git a/sdks/rs/src/lib.rs b/sdks/rs/src/lib.rs index 9abfdc2..52a494d 100644 --- a/sdks/rs/src/lib.rs +++ b/sdks/rs/src/lib.rs @@ -1297,6 +1297,9 @@ pub fn OtlpSink(endpoint: impl Into) -> SinkConfig { SinkConfig::HttpBatch { endpoint: endpoint.into(), api_key: None, + basic_username: None, + basic_password: None, + insecure: false, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, max_retries: 3, @@ -1645,6 +1648,9 @@ pub fn CollectorSinkWithEndpoint(endpoint: impl Into) -> SinkConfig { SinkConfig::HttpBatch { endpoint: endpoint.into(), api_key: None, + basic_username: None, + basic_password: None, + insecure: false, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, max_retries: 3, @@ -1665,6 +1671,9 @@ pub fn KafkaSink(endpoint: impl Into, _topic: impl Into) -> Sink SinkConfig::HttpBatch { endpoint: endpoint.into(), api_key: None, + basic_username: None, + basic_password: None, + insecure: false, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, max_retries: 3, @@ -1823,6 +1832,10 @@ pub fn WithCollectorEndpoint(endpoint: impl Into) -> core::options::Conf core::options::with_collector_endpoint(endpoint) } +pub fn WithCollectorName(collector_name: impl Into) -> core::options::ConfigOption { + core::options::with_collector_name(collector_name) +} + pub fn WithDuplicatePolicy(policy: impl Into) -> core::options::ConfigOption { core::options::with_duplicate_policy(policy) } diff --git a/sdks/rs/src/logger.rs b/sdks/rs/src/logger.rs index dbd5653..ed467ec 100644 --- a/sdks/rs/src/logger.rs +++ b/sdks/rs/src/logger.rs @@ -733,8 +733,11 @@ fn install_default_collector_sink(config: &mut Config) { } let http_batch = crate::SinkConfig::HttpBatch { - endpoint: endpoint.to_string(), - api_key: None, + endpoint: crate::config::collector_events_endpoint(endpoint, &config.collector_name), + api_key: (!config.api_key.is_empty()).then(|| config.api_key.clone()), + basic_username: config.basic_username.clone(), + basic_password: config.basic_password.clone(), + insecure: config.insecure, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, max_retries: 3, diff --git a/sdks/rs/src/middleware/actix/middleware.rs b/sdks/rs/src/middleware/actix/middleware.rs index d09edde..36e1968 100644 --- a/sdks/rs/src/middleware/actix/middleware.rs +++ b/sdks/rs/src/middleware/actix/middleware.rs @@ -103,7 +103,7 @@ pub mod actix_impl { Ok(res) } Err(err) => { - let _ = logger.finish_error(&mut ctx, &err.to_string()); + let _ = logger.finish_error(&mut ctx, err.to_string()); let _ = logger.emit(&ctx); Err(err) } diff --git a/sdks/rs/src/sink.rs b/sdks/rs/src/sink.rs index 6d8b46d..608c922 100644 --- a/sdks/rs/src/sink.rs +++ b/sdks/rs/src/sink.rs @@ -2,6 +2,7 @@ use crate::config::SinkConfig; use crate::core::client::CollectorHttpClient; use crate::generated::spec_contract::parse_collector_response_value; use crate::internal::retry::RetryPolicy; +use base64::Engine as _; use flate2::write::GzEncoder; use flate2::Compression; use serde_json::Value; @@ -102,12 +103,24 @@ pub fn write_sink_with_ack( } SinkConfig::Noop => Ok(()), SinkConfig::HttpBatch { - endpoint, ndjson, .. + endpoint, + ndjson, + api_key, + basic_username, + basic_password, + insecure, + .. } => { + let auth = ( + api_key.as_deref(), + basic_username.as_deref(), + basic_password.as_deref(), + *insecure, + ); if *ndjson { - post_http_ndjson_with_ack(endpoint, &[encoded.to_string()], ack) + post_http_ndjson_with_ack(endpoint, &[encoded.to_string()], ack, auth) } else { - post_http_batch_with_ack(endpoint, &[encoded.to_string()], ack) + post_http_batch_with_ack(endpoint, &[encoded.to_string()], ack, auth) } } } @@ -127,12 +140,24 @@ pub fn write_batch_sink_with_ack( ) -> io::Result<()> { match sink { SinkConfig::HttpBatch { - endpoint, ndjson, .. + endpoint, + ndjson, + api_key, + basic_username, + basic_password, + insecure, + .. } => { + let auth = ( + api_key.as_deref(), + basic_username.as_deref(), + basic_password.as_deref(), + *insecure, + ); if *ndjson { - post_http_ndjson_with_ack(endpoint, encoded_events, ack) + post_http_ndjson_with_ack(endpoint, encoded_events, ack, auth) } else { - post_http_batch_with_ack(endpoint, encoded_events, ack) + post_http_batch_with_ack(endpoint, encoded_events, ack, auth) } } _ => { @@ -161,15 +186,16 @@ pub fn close_sink(sink: &SinkConfig) -> io::Result<()> { #[allow(dead_code)] fn post_http_batch(endpoint: &str, encoded_events: &[String]) -> io::Result<()> { - post_http_batch_with_ack(endpoint, encoded_events, None) + post_http_batch_with_ack(endpoint, encoded_events, None, (None, None, None, false)) } fn post_http_batch_with_ack( endpoint: &str, encoded_events: &[String], ack: Option<&CollectorAckHandler>, + auth: (Option<&str>, Option<&str>, Option<&str>, bool), ) -> io::Result<()> { - let client = collector_http_client(endpoint); + let client = collector_http_client(endpoint, auth); let payload = client.envelope(encoded_events); client .validate_envelope(&payload) @@ -193,6 +219,12 @@ fn post_http_batch_with_ack( api_key.clone() }; request = request.set(&client.auth_header, &auth_value); + } else if let (Some(username), Some(password)) = + (&client.basic_username, &client.basic_password) + { + let token = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + request = request.set(&client.auth_header, &format!("Basic {token}")); } let retry_policy = RetryPolicy { max_attempts: 3, @@ -271,15 +303,16 @@ fn notify_ack(ack: Option<&CollectorAckHandler>, raw: &str) { #[allow(dead_code)] fn post_http_ndjson(endpoint: &str, encoded_events: &[String]) -> io::Result<()> { - post_http_ndjson_with_ack(endpoint, encoded_events, None) + post_http_ndjson_with_ack(endpoint, encoded_events, None, (None, None, None, false)) } fn post_http_ndjson_with_ack( endpoint: &str, encoded_events: &[String], ack: Option<&CollectorAckHandler>, + auth: (Option<&str>, Option<&str>, Option<&str>, bool), ) -> io::Result<()> { - let client = collector_http_client(endpoint); + let client = collector_http_client(endpoint, auth); let body = encoded_events.join("\n"); let body_bytes = body.into_bytes(); @@ -299,6 +332,12 @@ fn post_http_ndjson_with_ack( api_key.clone() }; request = request.set(&client.auth_header, &auth_value); + } else if let (Some(username), Some(password)) = + (&client.basic_username, &client.basic_password) + { + let token = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + request = request.set(&client.auth_header, &format!("Basic {token}")); } let retry_policy = RetryPolicy { max_attempts: 3, @@ -448,14 +487,23 @@ fn retryable_error_message(value: &Value) -> Option { }) } -fn collector_http_client(endpoint: &str) -> CollectorHttpClient { - let mut client = CollectorHttpClient::new(endpoint.to_string()); - if let Ok(api_key) = env::var("LOZA_COLLECTOR_API_KEY") { +fn collector_http_client( + endpoint: &str, + auth: (Option<&str>, Option<&str>, Option<&str>, bool), +) -> CollectorHttpClient { + let (api_key, basic_username, basic_password, insecure) = auth; + let mut client = CollectorHttpClient::new(endpoint.to_string()).with_insecure(insecure); + if let Some(api_key) = api_key.filter(|value| !value.is_empty()) { + client = client.with_api_key(api_key.to_string()); + } else if let Ok(api_key) = env::var("LOZA_COLLECTOR_API_KEY") { let api_key = api_key.trim(); if !api_key.is_empty() { client = client.with_api_key(api_key.to_string()); } } + if let (Some(username), Some(password)) = (basic_username, basic_password) { + client = client.with_basic_auth(username.to_string(), password.to_string()); + } if let Ok(header) = env::var("LOZA_COLLECTOR_API_KEY_HEADER") { let header = header.trim(); if !header.is_empty() { diff --git a/sdks/rs/src/sinks/httpbatch/config.rs b/sdks/rs/src/sinks/httpbatch/config.rs index ebb15e2..a2a56f2 100644 --- a/sdks/rs/src/sinks/httpbatch/config.rs +++ b/sdks/rs/src/sinks/httpbatch/config.rs @@ -3,19 +3,40 @@ use crate::generated::spec_contract::CollectorResponse; use crate::SinkConfig; use serde_json::Value; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct HttpBatchSinkConfig { pub endpoint: String, pub api_key: Option, + pub basic_username: Option, + pub basic_password: Option, + pub insecure: bool, pub timeout_ms: u64, pub max_batch_bytes: usize, } +impl std::fmt::Debug for HttpBatchSinkConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpBatchSinkConfig") + .field("endpoint", &self.endpoint) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field( + "basic_credentials", + &self.basic_username.as_ref().map(|_| ""), + ) + .field("timeout_ms", &self.timeout_ms) + .field("max_batch_bytes", &self.max_batch_bytes) + .finish() + } +} + impl HttpBatchSinkConfig { pub fn new(endpoint: impl Into) -> Self { Self { endpoint: endpoint.into(), api_key: None, + basic_username: None, + basic_password: None, + insecure: false, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, } @@ -25,6 +46,20 @@ impl HttpBatchSinkConfig { self.api_key = Some(api_key.into()); self } + pub fn with_basic_auth( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.basic_username = Some(username.into()); + self.basic_password = Some(password.into()); + self + } + + pub fn with_insecure(mut self, insecure: bool) -> Self { + self.insecure = insecure; + self + } pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { self.timeout_ms = timeout_ms; @@ -35,6 +70,9 @@ impl HttpBatchSinkConfig { SinkConfig::HttpBatch { endpoint: self.endpoint.clone(), api_key: self.api_key.clone(), + basic_username: self.basic_username.clone(), + basic_password: self.basic_password.clone(), + insecure: self.insecure, timeout_ms: self.timeout_ms, max_batch_bytes: self.max_batch_bytes, max_retries: 3, @@ -44,10 +82,15 @@ impl HttpBatchSinkConfig { } pub fn client(&self) -> CollectorHttpClient { - let mut client = CollectorHttpClient::new(self.endpoint.clone()); + let mut client = + CollectorHttpClient::new(self.endpoint.clone()).with_insecure(self.insecure); client.timeout_ms = self.timeout_ms; if let Some(api_key) = &self.api_key { client = client.with_api_key(api_key.clone()); + } else if let (Some(username), Some(password)) = + (&self.basic_username, &self.basic_password) + { + client = client.with_basic_auth(username.clone(), password.clone()); } client } diff --git a/sdks/rs/src/sinks/mod.rs b/sdks/rs/src/sinks/mod.rs index d95f6a2..d4f3c0a 100644 --- a/sdks/rs/src/sinks/mod.rs +++ b/sdks/rs/src/sinks/mod.rs @@ -30,6 +30,9 @@ pub fn otlp_sink(endpoint: impl Into) -> SinkConfig { SinkConfig::HttpBatch { endpoint: endpoint.into(), api_key: None, + basic_username: None, + basic_password: None, + insecure: false, timeout_ms: 2_000, max_batch_bytes: 256 * 1024, max_retries: 3, diff --git a/sdks/rs/tests/client_creation.rs b/sdks/rs/tests/client_creation.rs index 30f2daf..7c7701c 100644 --- a/sdks/rs/tests/client_creation.rs +++ b/sdks/rs/tests/client_creation.rs @@ -8,3 +8,16 @@ fn client_creation_and_alias_helpers_work() { assert_eq!(aliased.config().service, "default"); assert_eq!(aliased.config().alias, "audit"); } + +#[test] +fn direct_basic_auth_rejects_remote_plaintext_http() { + let client = loza::core::CollectorHttpClient::new("http://collector.example.com") + .with_basic_auth("key-id", "secret"); + let error = client + .ingest(&[]) + .expect_err("remote Basic auth must not use plaintext HTTP"); + assert!( + error.contains("credentialed HTTP requires TLS"), + "unexpected error: {error}" + ); +} diff --git a/sdks/rs/tests/collector_e2e.rs b/sdks/rs/tests/collector_e2e.rs index 5af15db..e7152db 100644 --- a/sdks/rs/tests/collector_e2e.rs +++ b/sdks/rs/tests/collector_e2e.rs @@ -28,7 +28,10 @@ fn send_raw_envelope(envelope: &Value) -> (u16, String) { let client = HTTPClient::with_timeout_ms(5_000); let body = serde_json::to_vec(envelope).unwrap(); let req = HTTPRequest::new("POST", format!("{}/events", collector_url())) - .with_header("Authorization", format!("Bearer {}", env::var("LOZA_API_KEY").unwrap_or_default())) + .with_header( + "Authorization", + format!("Bearer {}", env::var("LOZA_API_KEY").unwrap_or_default()), + ) .with_header("Content-Type", "application/json") .with_body(body); let resp = client.send(&req).unwrap(); diff --git a/sdks/rs/tests/config_env.rs b/sdks/rs/tests/config_env.rs index a35f421..9e7a387 100644 --- a/sdks/rs/tests/config_env.rs +++ b/sdks/rs/tests/config_env.rs @@ -25,6 +25,63 @@ fn env_overrides_file_defaults() { assert!(!cfg.async_enabled); } +#[test] +fn plaintext_basic_auth_is_allowed_for_local_dsn_only() { + let cfg = Config::test("checkout") + .with_dsn("loza://localhost/project?tls=false") + .with_basic_auth("dsn-user", "dsn-secret"); + assert!( + cfg.validate().is_ok(), + "local plaintext DSNs should remain usable" + ); +} + +#[test] +fn remote_plaintext_basic_auth_is_rejected() { + let cfg = Config::test("checkout") + .with_dsn("loza://dsn-user:dsn-secret@collector.example/project?tls=false"); + assert!( + cfg.validate().is_err(), + "remote plaintext Basic-auth DSNs must be rejected" + ); +} + +#[test] +fn credentialed_dsn_configures_scoped_endpoint_and_preserves_api_key_precedence() { + let capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"; + let logger = loza::New( + Config::production("checkout") + .with_dsn(format!( + "loza://{capability}:@collector.example/public-collector?env=prod" + )) + .with_api_key("api-key"), + ); + + assert_eq!( + logger.config().collector_endpoint, + "https://collector.example:443" + ); + assert_eq!(logger.config().collector_name, "public-collector"); + match logger.config().sinks.first() { + Some(loza::SinkConfig::HttpBatch { + endpoint, + api_key, + basic_username, + basic_password, + .. + }) => { + assert_eq!( + endpoint, + "https://collector.example:443/collectors/public-collector/events" + ); + assert_eq!(api_key.as_deref(), Some("api-key")); + assert_eq!(basic_username.as_deref(), Some(capability)); + assert_eq!(basic_password.as_deref(), Some("")); + } + sink => panic!("expected scoped HTTP batch sink, got {sink:?}"), + } +} + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) diff --git a/sdks/rs/tests/domain_helpers_test.rs b/sdks/rs/tests/domain_helpers_test.rs index c7b3e9e..7699c9b 100644 --- a/sdks/rs/tests/domain_helpers_test.rs +++ b/sdks/rs/tests/domain_helpers_test.rs @@ -343,7 +343,7 @@ fn test_collector_api_stubs() { // Test client construction and URL formatting without making HTTP calls assert_eq!(client.tail_endpoint(), "http://localhost:9308/tail"); assert_eq!(client.sdk_name, "loza-rs"); - assert_eq!(client.sdk_version, "0.2.6"); + assert_eq!(client.sdk_version, env!("CARGO_PKG_VERSION")); // Envelope building test let envelope = client.envelope(&["{\"event\":\"test\"}".to_string()]); assert_eq!( diff --git a/sdks/rs/tests/dsn_test.rs b/sdks/rs/tests/dsn_test.rs index 67a2d8d..f309c95 100644 --- a/sdks/rs/tests/dsn_test.rs +++ b/sdks/rs/tests/dsn_test.rs @@ -1,26 +1,45 @@ -/// Shared test vectors from loza/spec/dsn/test-cases.json (25 cases: 12 valid, 13 invalid). - -fn assert_valid( - input: &str, - expect_scheme: &str, - expect_host: &str, - expect_port: u16, - expect_project: &str, - expect_env: &str, - expect_service: &str, - expect_tls: bool, - expect_transport: &str, - expect_base_url: &str, - expect_events_url: Option<&str>, - expect_batch_url: Option<&str>, - expect_otlp_url: Option<&str>, - expect_tail_ws_url: Option<&str>, -) { - let dsn = loza::dsn::parse(input).expect(&format!("expected valid DSN: {input}")); +// Shared test vectors from loza/spec/dsn/test-cases.json (25 cases: 12 valid, 13 invalid). +type ExpectedDsn<'a> = ( + &'a str, + &'a str, + u16, + &'a str, + &'a str, + &'a str, + bool, + &'a str, + &'a str, + Option<&'a str>, + Option<&'a str>, + Option<&'a str>, + Option<&'a str>, +); + +fn assert_valid(input: &str, expected: ExpectedDsn<'_>) { + let ( + expect_scheme, + expect_host, + expect_port, + expect_project, + expect_env, + expect_service, + expect_tls, + expect_transport, + expect_base_url, + expect_events_url, + expect_batch_url, + expect_otlp_url, + expect_tail_ws_url, + ) = expected; + let dsn = loza::dsn::parse(input).unwrap_or_else(|_| panic!("expected valid DSN: {input}")); assert_eq!(dsn.scheme, expect_scheme, "scheme mismatch for {input}"); assert_eq!(dsn.host, expect_host, "host mismatch for {input}"); assert_eq!(dsn.port, expect_port, "port mismatch for {input}"); assert_eq!(dsn.project, expect_project, "project mismatch for {input}"); + assert_eq!( + dsn.collector_name, expect_project, + "collector_name mismatch for {input}" + ); assert_eq!(dsn.env, expect_env, "env mismatch for {input}"); assert_eq!(dsn.service, expect_service, "service mismatch for {input}"); assert_eq!(dsn.tls, expect_tls, "tls mismatch for {input}"); @@ -57,19 +76,21 @@ fn assert_invalid(input: &str) { fn localhost_dev_with_explicit_port() { assert_valid( "loza://localhost:9308/demo?tls=false", - "loza", - "localhost", - 9308, - "demo", - "default", - "", - false, - "http", - "http://localhost:9308", - Some("http://localhost:9308/events"), - Some("http://localhost:9308/events/batch"), - Some("http://localhost:9308/otlp/logs"), - Some("ws://localhost:9308/tail"), + ( + "loza", + "localhost", + 9308, + "demo", + "default", + "", + false, + "http", + "http://localhost:9308", + Some("http://localhost:9308/collectors/demo/events"), + Some("http://localhost:9308/collectors/demo/events/batch"), + Some("http://localhost:9308/collectors/demo/otlp/logs"), + Some("ws://localhost:9308/collectors/demo/tail"), + ), ); } @@ -77,19 +98,21 @@ fn localhost_dev_with_explicit_port() { fn localhost_default_port_9308() { assert_valid( "loza://localhost/demo?tls=false", - "loza", - "localhost", - 9308, - "demo", - "default", - "", - false, - "http", - "http://localhost:9308", - Some("http://localhost:9308/events"), - Some("http://localhost:9308/events/batch"), - Some("http://localhost:9308/otlp/logs"), - Some("ws://localhost:9308/tail"), + ( + "loza", + "localhost", + 9308, + "demo", + "default", + "", + false, + "http", + "http://localhost:9308", + Some("http://localhost:9308/collectors/demo/events"), + Some("http://localhost:9308/collectors/demo/events/batch"), + Some("http://localhost:9308/collectors/demo/otlp/logs"), + Some("ws://localhost:9308/collectors/demo/tail"), + ), ); } @@ -97,19 +120,21 @@ fn localhost_default_port_9308() { fn prod_default_tls_true() { assert_valid( "loza://collector.example.com/demo", - "loza", - "collector.example.com", - 443, - "demo", - "default", - "", - true, - "http", - "https://collector.example.com:443", - Some("https://collector.example.com:443/events"), - Some("https://collector.example.com:443/events/batch"), - Some("https://collector.example.com:443/otlp/logs"), - Some("wss://collector.example.com:443/tail"), + ( + "loza", + "collector.example.com", + 443, + "demo", + "default", + "", + true, + "http", + "https://collector.example.com:443", + Some("https://collector.example.com:443/collectors/demo/events"), + Some("https://collector.example.com:443/collectors/demo/events/batch"), + Some("https://collector.example.com:443/collectors/demo/otlp/logs"), + Some("wss://collector.example.com:443/collectors/demo/tail"), + ), ); } @@ -117,19 +142,21 @@ fn prod_default_tls_true() { fn custom_env_and_service() { assert_valid( "loza://collector.example.com/demo?env=prod&service=api", - "loza", - "collector.example.com", - 443, - "demo", - "prod", - "api", - true, - "http", - "https://collector.example.com:443", - None, - None, - None, - None, + ( + "loza", + "collector.example.com", + 443, + "demo", + "prod", + "api", + true, + "http", + "https://collector.example.com:443", + None, + None, + None, + None, + ), ); } @@ -137,19 +164,21 @@ fn custom_env_and_service() { fn otlp_transport() { assert_valid( "loza://collector.example.com/demo?transport=otlp", - "loza", - "collector.example.com", - 443, - "demo", - "default", - "", - true, - "otlp", - "https://collector.example.com:443", - None, - None, - Some("https://collector.example.com:443/otlp/logs"), - None, + ( + "loza", + "collector.example.com", + 443, + "demo", + "default", + "", + true, + "otlp", + "https://collector.example.com:443", + None, + None, + Some("https://collector.example.com:443/collectors/demo/otlp/logs"), + None, + ), ); } @@ -157,19 +186,21 @@ fn otlp_transport() { fn grpc_transport() { assert_valid( "loza://collector.example.com/demo?transport=grpc", - "loza", - "collector.example.com", - 443, - "demo", - "default", - "", - true, - "grpc", - "https://collector.example.com:443", - None, - None, - None, - None, + ( + "loza", + "collector.example.com", + 443, + "demo", + "default", + "", + true, + "grpc", + "https://collector.example.com:443", + None, + None, + None, + None, + ), ); } @@ -177,19 +208,21 @@ fn grpc_transport() { fn loopback_127_defaults_tls_false() { assert_valid( "loza://127.0.0.1/demo", - "loza", - "127.0.0.1", - 9308, - "demo", - "default", - "", - false, - "http", - "http://127.0.0.1:9308", - None, - None, - None, - None, + ( + "loza", + "127.0.0.1", + 9308, + "demo", + "default", + "", + false, + "http", + "http://127.0.0.1:9308", + None, + None, + None, + None, + ), ); } @@ -197,19 +230,21 @@ fn loopback_127_defaults_tls_false() { fn ipv6_loopback_defaults_tls_false() { assert_valid( "loza://[::1]/demo", - "loza", - "::1", - 9308, - "demo", - "default", - "", - false, - "http", - "http://[::1]:9308", - None, - None, - None, - None, + ( + "loza", + "::1", + 9308, + "demo", + "default", + "", + false, + "http", + "http://[::1]:9308", + None, + None, + None, + None, + ), ); } @@ -217,19 +252,21 @@ fn ipv6_loopback_defaults_tls_false() { fn tls_auto_keeps_localhost_default() { assert_valid( "loza://localhost/demo?tls=auto", - "loza", - "localhost", - 9308, - "demo", - "default", - "", - false, - "http", - "http://localhost:9308", - None, - None, - None, - None, + ( + "loza", + "localhost", + 9308, + "demo", + "default", + "", + false, + "http", + "http://localhost:9308", + None, + None, + None, + None, + ), ); } @@ -237,19 +274,21 @@ fn tls_auto_keeps_localhost_default() { fn tls_auto_keeps_remote_default() { assert_valid( "loza://collector.example.com/demo?tls=auto", - "loza", - "collector.example.com", - 443, - "demo", - "default", - "", - true, - "http", - "https://collector.example.com:443", - None, - None, - None, - None, + ( + "loza", + "collector.example.com", + 443, + "demo", + "default", + "", + true, + "http", + "https://collector.example.com:443", + None, + None, + None, + None, + ), ); } @@ -257,19 +296,21 @@ fn tls_auto_keeps_remote_default() { fn explicit_tls_true_on_localhost() { assert_valid( "loza://localhost:8443/demo?tls=true", - "loza", - "localhost", - 8443, - "demo", - "default", - "", - true, - "http", - "https://localhost:8443", - None, - None, - None, - None, + ( + "loza", + "localhost", + 8443, + "demo", + "default", + "", + true, + "http", + "https://localhost:8443", + None, + None, + None, + None, + ), ); } @@ -277,19 +318,21 @@ fn explicit_tls_true_on_localhost() { fn explicit_port_4318_with_otlp() { assert_valid( "loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp", - "loza", - "collector.example.com", - 4318, - "backend", - "staging", - "auth", - true, - "otlp", - "https://collector.example.com:4318", - None, - None, - None, - None, + ( + "loza", + "collector.example.com", + 4318, + "backend", + "staging", + "auth", + true, + "otlp", + "https://collector.example.com:4318", + None, + None, + None, + None, + ), ); } @@ -336,8 +379,32 @@ fn reject_userinfo_key() { } #[test] -fn reject_userinfo_with_password() { - assert_invalid("loza://user:pass@collector.example.com/demo"); +fn parse_userinfo_credentials() { + let dsn = loza::dsn::parse("loza://user:pass@collector.example.com/demo") + .expect("credentialed DSN should parse"); + assert_eq!(dsn.username.as_deref(), Some("user")); + assert_eq!(dsn.password.as_deref(), Some("pass")); + assert!(!dsn.base_url.contains("user")); + assert!(!dsn.base_url.contains("pass")); + assert!(!format!("{dsn:?}").contains("Some(\"pass\")")); +} + +#[test] +fn public_credentials_route_and_redact_bearer_capability() { + let capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"; + let dsn = loza::dsn::parse(&format!( + "loza://{capability}:@collector.example.com/public-collector" + )) + .expect("public DSN must parse"); + + assert_eq!(dsn.username.as_deref(), Some(capability)); + assert_eq!(dsn.password.as_deref(), Some("")); + assert_eq!(dsn.collector_name, "public-collector"); + assert_eq!( + dsn.events_url, + "https://collector.example.com:443/collectors/public-collector/events" + ); + assert!(!format!("{dsn:?}").contains(capability)); } #[test] diff --git a/spec/conformance/manifest.json b/spec/conformance/manifest.json index dc6e5bb..bfe3d27 100644 --- a/spec/conformance/manifest.json +++ b/spec/conformance/manifest.json @@ -1,6 +1,6 @@ { "version": "v1", - "product_version": "0.2.6", + "product_version": "0.3.0", "strict_schema": "../schema/event.strict.schema.json", "loose_schema": "../schema/event.loose.schema.json", "valid": [ diff --git a/spec/docs/sdk-parity-manifest.json b/spec/docs/sdk-parity-manifest.json index b0da7b2..3cfee69 100644 --- a/spec/docs/sdk-parity-manifest.json +++ b/spec/docs/sdk-parity-manifest.json @@ -1,8 +1,8 @@ { - "version": "0.2.6", + "version": "0.3.0", "package": "loza", "scope": "lightweight-sdk", - "stability": "product-parity-v0.2.6", + "stability": "product-parity-v0.3.0", "policy": "full-product-method-family", "sdks": ["go", "python", "rust", "javascript"], "lifecycle": [ diff --git a/spec/dsn/README.md b/spec/dsn/README.md index 2d51414..b877dc4 100644 --- a/spec/dsn/README.md +++ b/spec/dsn/README.md @@ -36,6 +36,34 @@ fmt.Println(d.EventsURL) // http://localhost:9308/events fmt.Println(d.TailWSURL) // ws://localhost:9308/tail ``` +## Credentialed DSNs + +Credentials are optional userinfo in PostgreSQL-style form: + +```text +loza://:@/?env=prod +``` + +The username is the Collector key ID and the password is that key's secret. +Both values are percent-decoded by the parser; URL-reserved password characters +must be percent-encoded (for example, `s%40cret%3Avalue`). Empty credentials, +malformed escapes, usernames containing `:` or whitespace, and unencoded +reserved password characters are rejected. + +Parsed credentials are exposed as `Username` and `Password`, but never appear +in `BaseURL`, `EventsURL`, `BatchURL`, `OTLPURL`, or `TailWSURL`. SDKs send +credentialed DSNs as HTTP Basic authentication and use TLS by default. Do not +place secrets in logs or general-purpose DSN strings; use a redacted +representation when displaying configuration. + +For SDK configuration, explicitly supplied code credentials take precedence +over credentials in a code-supplied DSN, which take precedence over +environment DSN credentials (`LOZA_DSN`). `LOZA_API_KEY` remains the +highest-priority token credential. `LOZA_COLLECTOR_URL` changes only the +endpoint and does not override DSN-derived environment, service, or +credentials. A DSN without userinfo does not clear credentials configured +separately. + ## Cross-SDK validation Each SDK should load `test-cases.json` and run its parser against all cases, diff --git a/spec/dsn/dsn.go b/spec/dsn/dsn.go index 25e66c5..e7c4876 100644 --- a/spec/dsn/dsn.go +++ b/spec/dsn/dsn.go @@ -5,11 +5,18 @@ // // Format: // -// loza://[host][:port]/[project]?env=&service=&tls=&transport= +// loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport= +// +// A private credential is username:password. A public bearer capability uses +// lx_pub_...: with an explicitly empty password. +// Userinfo credentials are percent-decoded and exposed in Username/Password. +// They are never included in resolved endpoint URLs. // // Examples: // -// loza://localhost:9308/my-app?env=dev&tls=false +// loza://localhost:9308/demo?env=dev&tls=false +// loza://key-id:s%40cret@collector.example.com/my-app?env=prod +// loza://lx_pub_...:@collector.example.com/my-app?env=prod // loza://collector.example.com/my-app?env=prod&tls=true // loza://loza.internal:4318/backend?env=staging&service=auth&transport=otlp package dsn @@ -19,23 +26,37 @@ import ( "net/url" "strconv" "strings" + "unicode" ) // LozaDSN holds the parsed and resolved values from a loza:// connection URI. type LozaDSN struct { - Scheme string // always "loza" - Host string // hostname (no port) - Port int // resolved port number - Project string // path segment (project name) - Env string // environment name (default: "default") - Service string // optional service name - TLS bool // whether to use HTTPS - Transport string // "http", "otlp", or "grpc" (default: "http") - BaseURL string // resolved http(s)://host:port - EventsURL string // base + /events - BatchURL string // base + /events/batch - OTLPURL string // base + /otlp/logs - TailWSURL string // ws(s)://host:port/tail + Scheme string // always "loza" + Username string // percent-decoded private key ID or public bearer capability + Password string // percent-decoded private key secret; empty for public capabilities + Host string // hostname (no port) + Port int // resolved port number + CollectorName string // canonical collector slug from the required path + Project string // deprecated compatibility alias for CollectorName + Env string // environment name (default: "default") + Service string // optional service name + TLS bool // whether to use HTTPS + Transport string // "http", "otlp", or "grpc" (default: "http") + BaseURL string // resolved http(s)://host:port; never includes credentials + EventsURL string // base + /collectors/{collector}/events + BatchURL string // base + /collectors/{collector}/events/batch + OTLPURL string // base + /collectors/{collector}/otlp/logs + TailWSURL string // ws(s)://host:port/collectors/{collector}/tail +} + +// String returns a credential-free representation suitable for logs. +func (d LozaDSN) String() string { + return d.BaseURL +} + +// GoString returns a credential-free representation for %#v formatting. +func (d LozaDSN) GoString() string { + return fmt.Sprintf("dsn.LozaDSN{BaseURL:%q}", d.BaseURL) } // Parse parses a raw loza:// connection URI into a LozaDSN. @@ -43,8 +64,10 @@ type LozaDSN struct { // Validation rules: // - Scheme must be loza:// // - Host is required (loza:// or loza:///project are rejected) -// - Project path is required (loza://host is rejected) -// - No userinfo allowed (loza://user:pass@host/project is rejected) +// - Collector path is required (loza://host is rejected) +// - Private userinfo must contain non-empty username/password +// - Public userinfo is lx_pub_...: with an explicitly empty password +// - Username cannot contain a colon or whitespace after decoding // - tls must be "true", "false", or "auto" // - transport must be "http", "otlp", or "grpc" // - Port must be 1-65535 if specified @@ -69,12 +92,25 @@ func Parse(raw string) (*LozaDSN, error) { // Parse as URL. The loza:// scheme is valid for url.Parse. u, err := url.Parse(raw) if err != nil { - return nil, fmt.Errorf("invalid Loza DSN: %w", err) + // Do not wrap URL parser details: malformed input may contain secrets. + return nil, fmt.Errorf("invalid Loza DSN: malformed URL") } - // Reject userinfo (API keys must not be in the URL). + username := "" + password := "" if u.User != nil { - return nil, fmt.Errorf("invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead") + var hasPassword bool + username = u.User.Username() + password, hasPassword = u.User.Password() + if !hasPassword || username == "" || (password == "" && !IsPublicCredentialUsername(username)) { + return nil, fmt.Errorf("invalid Loza DSN: credentials require username:password or lx_pub_...:") + } + if strings.Contains(username, ":") || hasWhitespace(username) { + return nil, fmt.Errorf("invalid Loza DSN: username contains an invalid character") + } + if err := validateRawPassword(raw); err != nil { + return nil, err + } } host := u.Hostname() @@ -84,10 +120,11 @@ func Parse(raw string) (*LozaDSN, error) { portStr := u.Port() - // Project is the path segment without leading slash. - project := strings.TrimPrefix(u.Path, "/") - if project == "" { - return nil, fmt.Errorf("invalid Loza DSN: project path is required, e.g. loza://host/my-project") + // The required path is the canonical collector identity. Project remains + // available as a compatibility alias for existing SDK consumers. + collectorName := strings.TrimPrefix(u.Path, "/") + if collectorName == "" { + return nil, fmt.Errorf("invalid Loza DSN: collector path is required, e.g. loza://host/my-collector") } q := u.Query() @@ -161,23 +198,100 @@ func Parse(raw string) (*LozaDSN, error) { baseURL := fmt.Sprintf("%s://%s:%d", scheme, hostPart, port) + collectorPath := url.PathEscape(collectorName) + collectorBaseURL := baseURL + "/collectors/" + collectorPath + collectorTailBaseURL := fmt.Sprintf("%s://%s:%d/collectors/%s", wsScheme, hostPart, port, collectorPath) + return &LozaDSN{ - Scheme: "loza", - Host: host, - Port: port, - Project: project, - Env: env, - Service: service, - TLS: tls, - Transport: transport, - BaseURL: baseURL, - EventsURL: baseURL + "/events", - BatchURL: baseURL + "/events/batch", - OTLPURL: baseURL + "/otlp/logs", - TailWSURL: fmt.Sprintf("%s://%s:%d/tail", wsScheme, hostPart, port), + Scheme: "loza", + Username: username, + Password: password, + Host: host, + Port: port, + CollectorName: collectorName, + Project: collectorName, + Env: env, + Service: service, + TLS: tls, + Transport: transport, + BaseURL: baseURL, + EventsURL: collectorBaseURL + "/events", + BatchURL: collectorBaseURL + "/events/batch", + OTLPURL: collectorBaseURL + "/otlp/logs", + TailWSURL: collectorTailBaseURL + "/tail", }, nil } +func hasWhitespace(value string) bool { + for _, r := range value { + if unicode.IsSpace(r) { + return true + } + } + return false +} + +// validateRawPassword enforces that URL-reserved password bytes are encoded. +// url.Parse has already validated percent escapes and decoded u.User. +func validateRawPassword(raw string) error { + const prefix = "loza://" + authorityEnd := len(raw) + for i := len(prefix); i < len(raw); i++ { + switch raw[i] { + case '/', '?', '#': + authorityEnd = i + i = len(raw) + } + } + + authority := raw[len(prefix):authorityEnd] + at := strings.LastIndexByte(authority, '@') + if at < 0 { + return fmt.Errorf("invalid Loza DSN: malformed credentials") + } + userinfo := authority[:at] + colon := strings.IndexByte(userinfo, ':') + if colon < 0 { + return fmt.Errorf("invalid Loza DSN: credentials require non-empty username and password") + } + + rawPassword := userinfo[colon+1:] + for i := range rawPassword { + if rawPassword[i] == '%' { + if i+2 >= len(rawPassword) || !isHexDigit(rawPassword[i+1]) || !isHexDigit(rawPassword[i+2]) { + return fmt.Errorf("invalid Loza DSN: malformed credentials") + } + continue + } + if isURLReserved(rawPassword[i]) { + return fmt.Errorf("invalid Loza DSN: password contains an unencoded reserved character") + } + } + return nil +} + +func isURLReserved(value byte) bool { + switch value { + case ':', '/', '?', '#', '[', ']', '@', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=': + return true + default: + return false + } +} + +func isHexDigit(value byte) bool { + return (value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f') || + (value >= 'A' && value <= 'F') +} + +// IsPublicCredentialUsername reports whether username is the public DSN bearer +// capability form. Its empty Basic password is intentional. +func IsPublicCredentialUsername(username string) bool { + const prefix = "lx_pub_" + return strings.HasPrefix(username, prefix) && len(username) > len(prefix) +} + // isLocalhost returns true for localhost, 127.0.0.1, or ::1. func isLocalhost(host string) bool { return host == "localhost" || host == "127.0.0.1" || host == "::1" diff --git a/spec/dsn/dsn_test.go b/spec/dsn/dsn_test.go index 896ad74..a8f8f08 100644 --- a/spec/dsn/dsn_test.go +++ b/spec/dsn/dsn_test.go @@ -1,6 +1,8 @@ package dsn import ( + "fmt" + "strings" "testing" ) @@ -9,6 +11,8 @@ func TestParse(t *testing.T) { name string input string valid bool + username string + password string host string port int project string @@ -34,10 +38,10 @@ func TestParse(t *testing.T) { tls: false, transport: "http", baseURL: "http://localhost:9308", - eventsURL: "http://localhost:9308/events", - batchURL: "http://localhost:9308/events/batch", - otlpURL: "http://localhost:9308/otlp/logs", - tailWSURL: "ws://localhost:9308/tail", + eventsURL: "http://localhost:9308/collectors/demo/events", + batchURL: "http://localhost:9308/collectors/demo/events/batch", + otlpURL: "http://localhost:9308/collectors/demo/otlp/logs", + tailWSURL: "ws://localhost:9308/collectors/demo/tail", }, { name: "localhost default port 9308", @@ -49,8 +53,8 @@ func TestParse(t *testing.T) { tls: false, transport: "http", baseURL: "http://localhost:9308", - eventsURL: "http://localhost:9308/events", - tailWSURL: "ws://localhost:9308/tail", + eventsURL: "http://localhost:9308/collectors/demo/events", + tailWSURL: "ws://localhost:9308/collectors/demo/tail", }, { name: "prod default tls=true", @@ -63,10 +67,10 @@ func TestParse(t *testing.T) { tls: true, transport: "http", baseURL: "https://collector.example.com:443", - eventsURL: "https://collector.example.com:443/events", - batchURL: "https://collector.example.com:443/events/batch", - otlpURL: "https://collector.example.com:443/otlp/logs", - tailWSURL: "wss://collector.example.com:443/tail", + eventsURL: "https://collector.example.com:443/collectors/demo/events", + batchURL: "https://collector.example.com:443/collectors/demo/events/batch", + otlpURL: "https://collector.example.com:443/collectors/demo/otlp/logs", + tailWSURL: "wss://collector.example.com:443/collectors/demo/tail", }, { name: "custom env and service", @@ -102,10 +106,10 @@ func TestParse(t *testing.T) { tls: true, transport: "grpc", baseURL: "https://collector.example.com:443", - eventsURL: "https://collector.example.com:443/events", - batchURL: "https://collector.example.com:443/events/batch", - otlpURL: "https://collector.example.com:443/otlp/logs", - tailWSURL: "wss://collector.example.com:443/tail", + eventsURL: "https://collector.example.com:443/collectors/demo/events", + batchURL: "https://collector.example.com:443/collectors/demo/events/batch", + otlpURL: "https://collector.example.com:443/collectors/demo/otlp/logs", + tailWSURL: "wss://collector.example.com:443/collectors/demo/tail", }, { name: "127.0.0.1 defaults to tls=false and port 9308", @@ -158,20 +162,104 @@ func TestParse(t *testing.T) { baseURL: "https://localhost:8443", }, { - name: "explicit port 4318 with otlp", - input: "loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp", - valid: true, - host: "collector.example.com", - port: 4318, - project: "backend", - env: "staging", - service: "auth", - tls: true, + name: "explicit port 4318 with otlp", + input: "loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp", + valid: true, + host: "collector.example.com", + port: 4318, + project: "backend", + env: "staging", + service: "auth", + tls: true, transport: "otlp", - baseURL: "https://collector.example.com:4318", + baseURL: "https://collector.example.com:4318", + }, + { + name: "credentialed DSN with percent-encoded password", + input: "loza://key-id:s%40cret%3Avalue%2Fpart@collector.example.com/demo", + valid: true, + username: "key-id", + password: "s@cret:value/part", + host: "collector.example.com", + port: 443, + project: "demo", + env: "default", + tls: true, + transport: "http", + baseURL: "https://collector.example.com:443", + }, + { + name: "public credential DSN", + input: "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example.com/public-collector?env=prod", + valid: true, + username: "lx_pub_6DJvd3D0izOaQx3n5BhKqN", + password: "", + host: "collector.example.com", + port: 443, + project: "public-collector", + env: "prod", + tls: true, + transport: "http", + baseURL: "https://collector.example.com:443", + eventsURL: "https://collector.example.com:443/collectors/public-collector/events", + batchURL: "https://collector.example.com:443/collectors/public-collector/events/batch", + otlpURL: "https://collector.example.com:443/collectors/public-collector/otlp/logs", + tailWSURL: "wss://collector.example.com:443/collectors/public-collector/tail", + }, + { + name: "credentialed DSN with percent-encoded username", + input: "loza://key%2Did:secret@collector.example.com/demo?env=prod", + valid: true, + username: "key-id", + password: "secret", + host: "collector.example.com", + port: 443, + project: "demo", + env: "prod", + tls: true, + transport: "http", + baseURL: "https://collector.example.com:443", + }, + + // ── Invalid credential cases ───────────────────────────────────────── + { + name: "reject empty username", + input: "loza://:secret@collector.example.com/demo", + valid: false, + }, + { + name: "reject empty password", + input: "loza://key:@collector.example.com/demo", + valid: false, + }, + { + name: "reject userinfo without password", + input: "loza://key@collector.example.com/demo", + valid: false, + }, + { + name: "reject username containing encoded colon", + input: "loza://key%3Aid:secret@collector.example.com/demo", + valid: false, + }, + { + name: "reject username containing encoded whitespace", + input: "loza://key%20id:secret@collector.example.com/demo", + valid: false, + }, + { + name: "reject unencoded reserved password character", + input: "loza://key:secret:part@collector.example.com/demo", + valid: false, + }, + { + name: "reject malformed percent escape in credentials", + input: "loza://key:secret%ZZ@collector.example.com/demo", + valid: false, }, // ── Invalid cases ──────────────────────────────────────────────────── + { name: "reject empty string", input: "", @@ -207,16 +295,6 @@ func TestParse(t *testing.T) { input: "loza://collector.example.com/", valid: false, }, - { - name: "reject userinfo (API key in URL)", - input: "loza://key@collector.example.com/demo", - valid: false, - }, - { - name: "reject userinfo with password", - input: "loza://user:pass@collector.example.com/demo", - valid: false, - }, { name: "reject invalid tls value", input: "loza://collector.example.com/demo?tls=maybe", @@ -271,6 +349,15 @@ func TestParse(t *testing.T) { if dsn.Project != tt.project { t.Errorf("Project = %q, want %q", dsn.Project, tt.project) } + if dsn.CollectorName != tt.project { + t.Errorf("CollectorName = %q, want %q", dsn.CollectorName, tt.project) + } + if dsn.Username != tt.username { + t.Errorf("Username = %q, want %q", dsn.Username, tt.username) + } + if dsn.Password != tt.password { + t.Errorf("Password = %q, want %q", dsn.Password, tt.password) + } if dsn.TLS != tt.tls { t.Errorf("TLS = %v, want %v", dsn.TLS, tt.tls) } @@ -304,6 +391,43 @@ func TestParse(t *testing.T) { } } +func TestParseCredentialErrorsDoNotEchoPassword(t *testing.T) { + const password = "super-secret" + _, err := Parse("loza://key:" + password + ":part@collector.example.com/demo") + if err == nil { + t.Fatal("expected an error for an unencoded reserved password character") + } + if strings.Contains(err.Error(), password) { + t.Fatalf("credential error echoed password: %q", err) + } +} + +func TestParseStringRedactsCredentials(t *testing.T) { + dsn, err := Parse("loza://key-id:s%40cret%3Avalue@collector.example.com/demo") + if err != nil { + t.Fatal(err) + } + for _, format := range []string{"%v", "%+v", "%#v"} { + rendered := fmt.Sprintf(format, dsn) + if strings.Contains(rendered, "s@cret:value") || strings.Contains(rendered, "key-id") { + t.Fatalf("format %q exposed credentials: %s", format, rendered) + } + } +} + +func TestParsePublicCredentialRedactsBearerCapability(t *testing.T) { + const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN" + dsn, err := Parse("loza://" + capability + ":@collector.example.com/demo") + if err != nil { + t.Fatal(err) + } + for _, format := range []string{"%v", "%+v", "%#v"} { + if rendered := fmt.Sprintf(format, dsn); strings.Contains(rendered, capability) { + t.Fatalf("format %q leaked public capability: %q", format, rendered) + } + } +} + func TestParseEnvDefault(t *testing.T) { dsn, err := Parse("loza://localhost/demo?tls=false") if err != nil { diff --git a/spec/dsn/test-cases.json b/spec/dsn/test-cases.json index ce526dd..ca4caee 100644 --- a/spec/dsn/test-cases.json +++ b/spec/dsn/test-cases.json @@ -17,10 +17,10 @@ "tls": false, "transport": "http", "baseURL": "http://localhost:9308", - "eventsURL": "http://localhost:9308/events", - "batchURL": "http://localhost:9308/events/batch", - "otlpURL": "http://localhost:9308/otlp/logs", - "tailWSURL": "ws://localhost:9308/tail" + "eventsURL": "http://localhost:9308/collectors/demo/events", + "batchURL": "http://localhost:9308/collectors/demo/events/batch", + "otlpURL": "http://localhost:9308/collectors/demo/otlp/logs", + "tailWSURL": "ws://localhost:9308/collectors/demo/tail" } }, { @@ -37,10 +37,10 @@ "tls": false, "transport": "http", "baseURL": "http://localhost:9308", - "eventsURL": "http://localhost:9308/events", - "batchURL": "http://localhost:9308/events/batch", - "otlpURL": "http://localhost:9308/otlp/logs", - "tailWSURL": "ws://localhost:9308/tail" + "eventsURL": "http://localhost:9308/collectors/demo/events", + "batchURL": "http://localhost:9308/collectors/demo/events/batch", + "otlpURL": "http://localhost:9308/collectors/demo/otlp/logs", + "tailWSURL": "ws://localhost:9308/collectors/demo/tail" } }, { @@ -57,10 +57,10 @@ "tls": true, "transport": "http", "baseURL": "https://collector.example.com:443", - "eventsURL": "https://collector.example.com:443/events", - "batchURL": "https://collector.example.com:443/events/batch", - "otlpURL": "https://collector.example.com:443/otlp/logs", - "tailWSURL": "wss://collector.example.com:443/tail" + "eventsURL": "https://collector.example.com:443/collectors/demo/events", + "batchURL": "https://collector.example.com:443/collectors/demo/events/batch", + "otlpURL": "https://collector.example.com:443/collectors/demo/otlp/logs", + "tailWSURL": "wss://collector.example.com:443/collectors/demo/tail" } }, { @@ -93,7 +93,7 @@ "tls": true, "transport": "otlp", "baseURL": "https://collector.example.com:443", - "otlpURL": "https://collector.example.com:443/otlp/logs" + "otlpURL": "https://collector.example.com:443/collectors/demo/otlp/logs" } }, { @@ -208,6 +208,67 @@ "baseURL": "https://collector.example.com:4318" } }, + { + "name": "credentialed DSN with percent-encoded password", + "input": "loza://key-id:s%40cret%3Avalue%2Fpart@collector.example.com/demo", + "valid": true, + "expected": { + "scheme": "loza", + "username": "key-id", + "password": "s@cret:value/part", + "host": "collector.example.com", + "port": 443, + "project": "demo", + "collectorName": "demo", + "env": "default", + "service": "", + "tls": true, + "transport": "http", + "baseURL": "https://collector.example.com:443" + } + }, + { + "name": "credentialed DSN with percent-encoded username", + "input": "loza://key%2Did:secret@collector.example.com/demo?env=prod", + "valid": true, + "expected": { + "scheme": "loza", + "username": "key-id", + "password": "secret", + "host": "collector.example.com", + "port": 443, + "project": "demo", + "env": "prod", + "service": "", + "tls": true, + "transport": "http", + "baseURL": "https://collector.example.com:443", + "collectorName": "demo" + } + }, + { + "name": "public credential DSN", + "input": "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example.com/public-collector?env=prod", + "valid": true, + "expected": { + "scheme": "loza", + "username": "lx_pub_6DJvd3D0izOaQx3n5BhKqN", + "password": "", + "host": "collector.example.com", + "port": 443, + "project": "public-collector", + "collectorName": "public-collector", + "env": "prod", + "service": "", + "tls": true, + "transport": "http", + "baseURL": "https://collector.example.com:443", + "eventsURL": "https://collector.example.com:443/collectors/public-collector/events", + "batchURL": "https://collector.example.com:443/collectors/public-collector/events/batch", + "otlpURL": "https://collector.example.com:443/collectors/public-collector/otlp/logs", + "tailWSURL": "wss://collector.example.com:443/collectors/public-collector/tail" + } + }, { "name": "reject empty string", "input": "", @@ -251,14 +312,44 @@ "expected": null }, { - "name": "reject userinfo (API key in URL)", + "name": "reject empty username", + "input": "loza://:secret@collector.example.com/demo", + "valid": false, + "expected": null + }, + { + "name": "reject empty password", + "input": "loza://key:@collector.example.com/demo", + "valid": false, + "expected": null + }, + { + "name": "reject userinfo without password", "input": "loza://key@collector.example.com/demo", "valid": false, "expected": null }, { - "name": "reject userinfo with password", - "input": "loza://user:pass@collector.example.com/demo", + "name": "reject username containing encoded colon", + "input": "loza://key%3Aid:secret@collector.example.com/demo", + "valid": false, + "expected": null + }, + { + "name": "reject username containing encoded whitespace", + "input": "loza://key%20id:secret@collector.example.com/demo", + "valid": false, + "expected": null + }, + { + "name": "reject unencoded reserved password character", + "input": "loza://key:secret:part@collector.example.com/demo", + "valid": false, + "expected": null + }, + { + "name": "reject malformed percent escape in credentials", + "input": "loza://key:secret%ZZ@collector.example.com/demo", "valid": false, "expected": null }, diff --git a/spec/generated/conformance_manifest.json b/spec/generated/conformance_manifest.json index dc6e5bb..bfe3d27 100644 --- a/spec/generated/conformance_manifest.json +++ b/spec/generated/conformance_manifest.json @@ -1,6 +1,6 @@ { "version": "v1", - "product_version": "0.2.6", + "product_version": "0.3.0", "strict_schema": "../schema/event.strict.schema.json", "loose_schema": "../schema/event.loose.schema.json", "valid": [ diff --git a/spec/generated/contract/conformance_manifest.json b/spec/generated/contract/conformance_manifest.json index dc6e5bb..bfe3d27 100644 --- a/spec/generated/contract/conformance_manifest.json +++ b/spec/generated/contract/conformance_manifest.json @@ -1,6 +1,6 @@ { "version": "v1", - "product_version": "0.2.6", + "product_version": "0.3.0", "strict_schema": "../schema/event.strict.schema.json", "loose_schema": "../schema/event.loose.schema.json", "valid": [ diff --git a/spec/generated/contract/loza-contract.json b/spec/generated/contract/loza-contract.json index 607c254..b6e827b 100644 --- a/spec/generated/contract/loza-contract.json +++ b/spec/generated/contract/loza-contract.json @@ -1,5 +1,5 @@ { - "product_version": "0.2.6", + "product_version": "0.3.0", "spec_version": "v1", "api_version": "v1", "event_version": "v1", diff --git a/spec/generated/python/loza_contract.py b/spec/generated/python/loza_contract.py index 7aa0b55..98bc2a4 100644 --- a/spec/generated/python/loza_contract.py +++ b/spec/generated/python/loza_contract.py @@ -6,7 +6,7 @@ from typing import Any, Iterable CONTRACT = { - "product_version": "0.2.6", + "product_version": "0.3.0", "spec_version": "v1", "api_version": "v1", "event_version": "v1", @@ -298,7 +298,7 @@ }, "fixtures": { "version": "v1", - "product_version": "0.2.6", + "product_version": "0.3.0", "strict_schema": "../schema/event.strict.schema.json", "loose_schema": "../schema/event.loose.schema.json", "valid": [ diff --git a/spec/generated/rust/contract.rs b/spec/generated/rust/contract.rs index 620d4e6..3351914 100644 --- a/spec/generated/rust/contract.rs +++ b/spec/generated/rust/contract.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; pub const CONTRACT_JSON: &str = r#"{ - \"product_version\": \"0.2.6\", + \"product_version\": \"0.3.0\", \"spec_version\": \"v1\", \"api_version\": \"v1\", \"event_version\": \"v1\", @@ -292,7 +292,7 @@ pub const CONTRACT_JSON: &str = r#"{ }, \"fixtures\": { \"version\": \"v1\", - \"product_version\": \"0.2.6\", + \"product_version\": \"0.3.0\", \"strict_schema\": \"../schema/event.strict.schema.json\", \"loose_schema\": \"../schema/event.loose.schema.json\", \"valid\": [ diff --git a/spec/loza-spec.yaml b/spec/loza-spec.yaml index 9453dae..fa88881 100644 --- a/spec/loza-spec.yaml +++ b/spec/loza-spec.yaml @@ -1,6 +1,6 @@ name: loza-spec kind: spec -version: 0.2.6 +version: 0.3.0 description: Shared LOZA wire, schema, event, and conformance contract license: MIT repository: https://github.com/Astraive/loza diff --git a/tests/cross-sdk-equivalence/test_equivalence.py b/tests/cross-sdk-equivalence/test_equivalence.py index 06b4e36..dbfdc90 100644 --- a/tests/cross-sdk-equivalence/test_equivalence.py +++ b/tests/cross-sdk-equivalence/test_equivalence.py @@ -18,11 +18,13 @@ "service": "equivalence-test", "event": "test.cross-sdk", "kind": "http", - "outcome": "success", + "method": "GET", + "path": "/api/test", + "http": { + "status": 200, + }, + "status_code": 200, "attrs": { - "http.method": "GET", - "http.path": "/api/test", - "http.status_code": 200, "custom.label": "equivalence-check", }, } @@ -56,9 +58,11 @@ def emit_event_via_api(event: dict) -> dict | None: def query_events(marker: str) -> list[dict]: - """Query stored events from the collector using /query with SQL.""" - # Query the raw column for events matching the test marker - sql = f"SELECT raw FROM events WHERE raw LIKE '%{marker}%' ORDER BY event_id DESC" + """Query projected event fields without relying on encrypted raw payloads.""" + sql = ( + "SELECT event_id, event_name, service, outcome, http_method, http_path, status_code " + f"FROM events WHERE event_id = '{marker}' ORDER BY event_id DESC" + ) payload = json.dumps({"query": sql}).encode("utf-8") req = urllib.request.Request( f"{COLLECTOR_URL}/query", @@ -69,34 +73,23 @@ def query_events(marker: str) -> list[dict]: try: with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) - rows = data.get("rows", []) - events = [] - for row in rows: - raw = row.get("raw") - if raw: - try: - events.append(json.loads(raw) if isinstance(raw, str) else raw) - except json.JSONDecodeError: - pass - return events + return data.get("rows", []) except (urllib.error.URLError, OSError) as e: print(f" WARN: Query failed: {e}", file=sys.stderr) return [] -def canonical_match(a: dict, b: dict) -> bool: - """Check if two events match on canonical fields.""" - fields = ["service", "event", "kind", "outcome"] - for f in fields: - if a.get(f) != b.get(f): - return False - # Check attrs subset - a_attrs = a.get("attrs", {}) - b_attrs = b.get("attrs", {}) - for key in CANONICAL_EVENT["attrs"]: - if a_attrs.get(key) != b_attrs.get(key): - return False - return True +def canonical_match(projected: dict, expected: dict) -> bool: + """Check canonical fields exposed by the Collector's encrypted schema.""" + return ( + projected.get("event_id") == expected.get("event_id") + and projected.get("event_name") == expected.get("event") + and projected.get("service") == expected.get("service") + and projected.get("outcome") == expected.get("outcome") + and projected.get("http_method") == expected.get("method") + and projected.get("http_path") == expected.get("path") + and projected.get("status_code") == expected.get("status_code") + ) def main(): @@ -115,6 +108,7 @@ def main(): # Emit the canonical event with a unique marker marker = f"equiv-{int(time.time())}" event = dict(CANONICAL_EVENT) + event["event_id"] = marker event["attrs"] = dict(CANONICAL_EVENT["attrs"]) event["attrs"]["test.marker"] = marker @@ -136,17 +130,8 @@ def main(): print("FAIL: No events found for marker={marker}") sys.exit(1) - # Find our event by marker - found = None - for ev in events: - if ev.get("attrs", {}).get("test.marker") == marker: - found = ev - break + found = events[0] - if found is None: - print("FAIL: Could not find emitted event by marker") - print(f" Queried {len(events)} event(s)") - sys.exit(1) print(" Found matching event. Verifying canonical fields...") @@ -155,8 +140,8 @@ def main(): print("PASS: Canonical fields match between emitted and stored event") else: print("FAIL: Canonical fields do not match") - print(f" Expected: {json.dumps({k: event[k] for k in ['service','event','kind','outcome']})}") - print(f" Got: {json.dumps({k: found.get(k) for k in ['service','event','kind','outcome']})}") + print(f" Expected: {json.dumps({k: event.get(k) for k in ['event_id', 'service', 'event', 'outcome']})}") + print(f" Got: {json.dumps({k: found.get(k) for k in ['event_id', 'event_name', 'service', 'outcome']})}") sys.exit(1) # ── v0.2.0: Release field equivalence ────────────────────────────────── @@ -168,8 +153,9 @@ def main(): print("1. Release field equivalence...") release_event = dict(CANONICAL_EVENT) release_event["attrs"] = dict(CANONICAL_EVENT["attrs"]) - release_event["release"] = "1.2.3" release_marker = f"equiv-release-{int(time.time())}" + release_event["event_id"] = release_marker + release_event["release"] = "1.2.3" release_event["attrs"]["test.marker"] = release_marker result = emit_event_via_api(release_event) @@ -178,25 +164,21 @@ def main(): else: time.sleep(0.5) events = query_events(release_marker) - found_release = None - for ev in events: - if ev.get("attrs", {}).get("test.marker") == release_marker: - found_release = ev - break - if found_release: - rel = found_release.get("release") - if rel == "1.2.3": - print(" PASS: Release field preserved correctly") - else: - print(f" WARN: Release field mismatch: expected 1.2.3, got {rel}") + if events and canonical_match(events[0], release_event): + print(" PASS: Release event accepted and stored") else: - print(" WARN: Release field event not found") + print(" WARN: Release event was not found in projected storage") # 2. Notice level equivalence print("2. Notice level equivalence...") notice_marker = f"equiv-notice-{int(time.time())}" - notice_event = dict(CANONICAL_EVENT, event=f"test.notice.{int(time.time())}", level="notice", - attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": notice_marker})) + notice_event = dict( + CANONICAL_EVENT, + event=f"test.notice.{int(time.time())}", + event_id=notice_marker, + level="notice", + attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": notice_marker}), + ) result = emit_event_via_api(notice_event) if result: print(" PASS: Notice-level event submitted") @@ -207,8 +189,15 @@ def main(): print("3. Agent/ai kind equivalence...") for kind in ("agent", "ai"): kind_marker = f"equiv-kind-{kind}-{int(time.time())}" - result = emit_event_via_api(dict(CANONICAL_EVENT, event=f"test.{kind}.{int(time.time())}", kind=kind, - attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": kind_marker}))) + result = emit_event_via_api( + dict( + CANONICAL_EVENT, + event=f"test.{kind}.{int(time.time())}", + event_id=kind_marker, + kind=kind, + attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": kind_marker}), + ) + ) if result: print(f" PASS: kind={kind} submitted") else: @@ -217,7 +206,7 @@ def main(): # 4. Domain helper equivalence (money, percent, httpStatus) print("4. Domain helper equivalence...") domain_marker = f"equiv-domain-{int(time.time())}" - domain_event = dict(CANONICAL_EVENT, event=f"test.domain.{int(time.time())}") + domain_event = dict(CANONICAL_EVENT, event=f"test.domain.{int(time.time())}", event_id=domain_marker) domain_event["attrs"] = { **CANONICAL_EVENT["attrs"], "test.marker": domain_marker, @@ -229,15 +218,10 @@ def main(): if result: time.sleep(0.5) events = query_events(domain_marker) - found_domain = None - for ev in events: - if ev.get("attrs", {}).get("test.marker") == domain_marker: - found_domain = ev - break - if found_domain: - print(" PASS: Domain helper fields preserved") + if events and canonical_match(events[0], domain_event): + print(" PASS: Domain helper fields preserved in projected storage") else: - print(" WARN: Domain event not found in query") + print(" WARN: Domain event was not found in projected storage") else: print(" WARN: Domain event emit failed") diff --git a/tests/integration/verification_matrix.py b/tests/integration/verification_matrix.py index 4dd1059..79a5ce1 100644 --- a/tests/integration/verification_matrix.py +++ b/tests/integration/verification_matrix.py @@ -311,15 +311,15 @@ def run_collector_smoke() -> list[StepResult]: ) unauthorized_status, unauthorized_body = _http_request("GET", collector.base_url + "/status") + unauthorized_ok = ( + unauthorized_status == 401 + and unauthorized_body.strip() == '{"error":"unauthorized"}' + ) results.append( StepResult( id="collector.unauthenticated_request", category="collector_runtime", - status=( - "implemented_and_passing" - if unauthorized_status == 401 and unauthorized_body.strip() == '{"error":"unauthorized"}' - else "implemented_and_failing" - ), + status="implemented_and_passing" if unauthorized_ok else "implemented_and_failing", duration_s=0.0, details={"response": unauthorized_body}, ) @@ -499,17 +499,18 @@ def run_cortex_full_stack() -> list[StepResult]: up.details["reason"] = "docker engine is unavailable" results.append(up) if up.status != "implemented_and_passing": - results.append( - _run( - "cortex.compose.down", - "cortex_runtime", - ["docker", "compose", "-f", "configs/docker-compose.yml", "down", "--remove-orphans"], - CORTEX_ROOT, - env=compose_env, - ok_returncodes=(0,), - timeout_s=60, + if up.status != "environment_blocked": + results.append( + _run( + "cortex.compose.down", + "cortex_runtime", + ["docker", "compose", "-f", "configs/docker-compose.yml", "down", "--remove-orphans"], + CORTEX_ROOT, + env=compose_env, + ok_returncodes=(0,), + timeout_s=60, + ) ) - ) return results try: