Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
config.yaml
.claude/settings.local.json
.claude/settings.local.json

.idea/
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ On startup, the service:

On shutdown (SIGINT/SIGTERM), the stream is deleted from the transmitter before the process exits.

## Supported Events

`ssf-forwarder` supports all CAEP event types defined in the [CAEP specification](https://openid.net/specs/openid-caep-1_0.html).

| Event type | URI |
|---|---|
| Session Revoked | `https://schemas.openid.net/secevent/caep/event-type/session-revoked` |
| Token Claims Change | `https://schemas.openid.net/secevent/caep/event-type/token-claims-change` |
| Credential Change | `https://schemas.openid.net/secevent/caep/event-type/credential-change` |
| Assurance Level Change | `https://schemas.openid.net/secevent/caep/event-type/assurance-level-change` |
| Device Compliance Change | `https://schemas.openid.net/secevent/caep/event-type/device-compliance-change` |
| Session Established | `https://schemas.openid.net/secevent/caep/event-type/session-established` |
| Session Presented | `https://schemas.openid.net/secevent/caep/event-type/session-presented` |
| Risk Level Change | `https://schemas.openid.net/secevent/caep/event-type/risk-level-change` |
| Verification | `https://schemas.openid.net/secevent/ssf/event-type/verification` |
| Stream Updated | `https://schemas.openid.net/secevent/ssf/event-type/stream-updated` |

## Development

```sh
Expand Down
1 change: 1 addition & 0 deletions cmd/ssf-forwarder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"syscall"
"time"

_ "github.com/twosense/ssf-forwarder/internal/caepext" // Register custom CAEP event parsers
"github.com/twosense/ssf-forwarder/internal/config"
"github.com/twosense/ssf-forwarder/internal/handler"
)
Expand Down
File renamed without changes.
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
services:
ssf-forwarder:
image: ghcr.io/twosense/ssf-forwarder:latest
# In a production environment, you will want to reference the image directly:
# image: ghcr.io/twosense/ssf-forwarder:latest
# For development, you can build the image locally:
build: .
Comment thread
tjhorner marked this conversation as resolved.
ports:
- "8080:8080"
volumes:
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ docker run --rm \

## Docker Compose

Copy the sample config and fill in your values:
Copy the example config and fill in your values:

```sh
cp config.sample.yaml config.yaml
cp config.example.yaml config.yaml
```

Then start the service:
Expand Down
128 changes: 128 additions & 0 deletions internal/caepext/risklevelchange.go
Comment thread
tjhorner marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Package caepext registers custom CAEP event type parsers not yet included
// in the upstream caep.dev/secevent library.
package caepext

import (
"encoding/json"

"github.com/sgnl-ai/caep.dev/secevent/pkg/event"
"github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep"
)

const EventTypeRiskLevelChange event.EventType = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change"

// RiskLevel represents the risk level values defined in Section 3.8.1 of the CAEP specification.
type RiskLevel string

const (
RiskLevelLow RiskLevel = "LOW"
RiskLevelMedium RiskLevel = "MEDIUM"
RiskLevelHigh RiskLevel = "HIGH"
)

// Principal identifies the type of entity involved in a risk event.
// The spec defines well-known values but permits any string.
type Principal string

const (
PrincipalUser Principal = "USER"
PrincipalDevice Principal = "DEVICE"
PrincipalSession Principal = "SESSION"
PrincipalTenant Principal = "TENANT"
PrincipalOrgUnit Principal = "ORG_UNIT"
PrincipalGroup Principal = "GROUP"
)

// RiskLevelChangePayload holds the event-specific claims for a risk-level-change event.
type RiskLevelChangePayload struct {
CurrentLevel RiskLevel `json:"current_level"`
PreviousLevel *RiskLevel `json:"previous_level,omitempty"`
Principal Principal `json:"principal"`
RiskReason string `json:"risk_reason,omitempty"`
}

// RiskLevelChangeEvent represents a CAEP risk-level-change event.
type RiskLevelChangeEvent struct {
caep.BaseCAEPEvent
RiskLevelChangePayload
}

var validRiskLevels = map[RiskLevel]bool{
RiskLevelLow: true,
RiskLevelMedium: true,
RiskLevelHigh: true,
}

func (e *RiskLevelChangeEvent) Validate() error {
if err := e.ValidateMetadata(); err != nil {
return err
}

if e.Principal == "" {
return event.NewError(event.ErrCodeMissingField, "missing required claim: principal", "principal", "")
}

if e.CurrentLevel == "" {
return event.NewError(event.ErrCodeMissingField, "missing required claim: current_level", "current_level", "")
}

if !validRiskLevels[e.CurrentLevel] {
return event.NewError(event.ErrCodeInvalidValue, "current_level must be LOW, MEDIUM, or HIGH", "current_level", string(e.CurrentLevel))
}

if e.PreviousLevel != nil && !validRiskLevels[*e.PreviousLevel] {
return event.NewError(event.ErrCodeInvalidValue, "previous_level must be LOW, MEDIUM, or HIGH", "previous_level", string(*e.PreviousLevel))
}

return nil
}

func (e *RiskLevelChangeEvent) Payload() any {
payload := e.RiskLevelChangePayload

if e.Metadata != nil {
return struct {
RiskLevelChangePayload
*caep.EventMetadata
}{
RiskLevelChangePayload: payload,
EventMetadata: e.Metadata,
}
}

return payload
}

func (e *RiskLevelChangeEvent) MarshalJSON() ([]byte, error) {
return json.Marshal(e.Payload())
}

func (e *RiskLevelChangeEvent) UnmarshalJSON(data []byte) error {
var payload struct {
RiskLevelChangePayload
*caep.EventMetadata
}

if err := json.Unmarshal(data, &payload); err != nil {
return event.NewError(event.ErrCodeParseError, "failed to parse risk-level-change event data", "", err.Error())
}

e.SetType(EventTypeRiskLevelChange)
e.RiskLevelChangePayload = payload.RiskLevelChangePayload
e.Metadata = payload.EventMetadata

return e.Validate()
}

func parseRiskLevelChangeEvent(data []byte) (event.Event, error) {
var e RiskLevelChangeEvent
if err := json.Unmarshal(data, &e); err != nil {
return nil, event.NewError(event.ErrCodeParseError, "failed to parse risk-level-change event", "", err.Error())
}

return &e, nil
}

func init() {
event.RegisterEventParser(EventTypeRiskLevelChange, parseRiskLevelChangeEvent)
}
136 changes: 136 additions & 0 deletions internal/caepext/risklevelchange_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package caepext

import (
"encoding/json"
"testing"

"github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep"
)

func TestRiskLevelChangeValidate(t *testing.T) {
prevLow := RiskLevelLow
prevInvalid := RiskLevel("medium")
negTS := int64(-1)

meta := caep.NewEventMetadata()
meta.EventTimestamp = &negTS

tests := []struct {
name string
event RiskLevelChangeEvent
wantErr bool
}{
{
name: "valid minimal",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow, Principal: PrincipalUser}},
},
{
name: "valid with optional fields",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{
CurrentLevel: RiskLevelHigh,
PreviousLevel: &prevLow,
Principal: PrincipalDevice,
RiskReason: "PASSWORD_FOUND_IN_DATA_BREACH",
}},
},
{
name: "missing principal",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow}},
wantErr: true,
},
{
name: "missing current_level",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{Principal: PrincipalUser}},
wantErr: true,
},
{
name: "invalid current_level",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: "medium", Principal: PrincipalUser}},
wantErr: true,
},
{
name: "invalid previous_level",
event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{
CurrentLevel: RiskLevelLow,
PreviousLevel: &prevInvalid,
Principal: PrincipalUser,
}},
wantErr: true,
},
{
name: "invalid metadata timestamp",
event: func() RiskLevelChangeEvent {
e := RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow, Principal: PrincipalUser}}
e.Metadata = meta
return e
}(),
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.event.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestRiskLevelChangeRoundtrip(t *testing.T) {
data, err := json.Marshal(map[string]any{
"current_level": "HIGH",
"previous_level": "LOW",
"principal": "DEVICE",
"risk_reason": "SUSPICIOUS_ACTIVITY",
"event_timestamp": int64(1615304991),
})
if err != nil {
t.Fatalf("marshaling input: %v", err)
}

var e RiskLevelChangeEvent
if err := json.Unmarshal(data, &e); err != nil {
t.Fatalf("UnmarshalJSON: %v", err)
}

if e.Type() != EventTypeRiskLevelChange {
t.Errorf("Type() = %q, want %q", e.Type(), EventTypeRiskLevelChange)
}
if e.CurrentLevel != RiskLevelHigh {
t.Errorf("CurrentLevel = %q, want %q", e.CurrentLevel, RiskLevelHigh)
}
if e.PreviousLevel == nil || *e.PreviousLevel != RiskLevelLow {
t.Errorf("PreviousLevel = %v, want %q", e.PreviousLevel, RiskLevelLow)
}
if e.Principal != PrincipalDevice {
t.Errorf("Principal = %q, want %q", e.Principal, PrincipalDevice)
}
if e.RiskReason != "SUSPICIOUS_ACTIVITY" {
t.Errorf("RiskReason = %q, want %q", e.RiskReason, "SUSPICIOUS_ACTIVITY")
}
}

func TestRiskLevelChangeUnmarshalValidates(t *testing.T) {
invalid, _ := json.Marshal(map[string]any{"current_level": "medium", "principal": "USER"})
var e RiskLevelChangeEvent
if err := json.Unmarshal(invalid, &e); err == nil {
t.Error("expected error for invalid current_level, got nil")
}
}

func TestRiskLevelChangeParser(t *testing.T) {
data, _ := json.Marshal(map[string]any{
"current_level": "LOW",
"principal": "USER",
})

e, err := parseRiskLevelChangeEvent(data)
if err != nil {
t.Fatalf("parseRiskLevelChangeEvent: %v", err)
}
if e.Type() != EventTypeRiskLevelChange {
t.Errorf("Type() = %q, want %q", e.Type(), EventTypeRiskLevelChange)
}
}
Loading
Loading