Help us shape the future of Go! Take the 2026 Go Developer Survey

sharedsignals

package
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 43 Imported by: 0

README

sharedsignals

Receives Shared Signals Framework events, so other identity providers can tell you when one of your users has been compromised.

When Google detects a session hijack or Okta sees an account takeover, they can push a Security Event Token to anyone subscribed. This plugin is the receiving end. It validates the SET signature against the transmitter's JWKS, checks the audience and timestamps, stores the signal, and feeds a risk score into riskengine.

The signals are worth having because they come from detection work somebody else already did, on infrastructure you do not run.

Use it when

  • Your users sign in through Google, Okta or Entra and you want to hear about compromises those providers detect
  • You are implementing CAEP for continuous access evaluation
  • You want a high-quality external input to your risk scoring

Skip it when

  • No transmitter is willing to push to you. This plugin only receives, and someone has to be sending
  • You have no riskengine registered. The signals get stored but nothing acts on them
  • You cannot expose a public HTTPS endpoint for the push path

Wiring

authsome.WithPlugin(riskengine.New()),
authsome.WithPlugin(sharedsignals.New(sharedsignals.Config{
    Audience:   "https://auth.example.com",
    SignalTTL:  24 * time.Hour,
})),

riskengine discovers this plugin as a contributor automatically.

Config

Field Type Default What it does
Audience string none The aud inbound SETs must carry. Streams inherit it
SignalTTL time.Duration 24h How long a stored signal stays active
MaxActionsPerHour int 100 Circuit breaker default for a new stream
ClockSkew time.Duration small Tolerance for an iat slightly in the future
MaxSETAge time.Duration generous How far in the past an iat may be
MaxBodyBytes int64 bounded Cap on the push request body
MaxRiskScore int 84 Ceiling on the score handed to riskengine
KeyRefreshInterval time.Duration default JWKS background refresh. Negative turns the ticker off

MaxRiskScore defaults to 84 for a reason worth knowing. A single contributor's score becomes the whole composite, and riskengine blocks at 85. At 100 a confirmed compromise would refuse the sign-in outright, locking out a user who holds correct credentials and a correct second factor. At 84 it challenges. Raise it to 100 if you would rather a confirmed compromise bar the door: that number is a policy statement, not a measurement.

Settings

Key Default
sharedsignals.enabled true
sharedsignals.signal_ttl_hours 24
sharedsignals.max_actions_per_hour 100
sharedsignals.risk_weight 2
sharedsignals.max_risk_score 84

Endpoints

Method Path Purpose
POST /v1/ssf/streams/:push_path/events Receive Security Event Tokens
GET /v1/ssf/admin/streams List inbound streams
POST /v1/ssf/admin/streams Register an inbound stream
PATCH /v1/ssf/admin/streams/:id Update a stream
DELETE /v1/ssf/admin/streams/:id Delete a stream

Lifecycle hooks

None on the sign-in path. It implements riskengine.RiskContributor, plus PermissionChecker for its admin routes, MigrationProvider for the stream and signal tables, and OnShutdown to stop the JWKS refresh ticker.

riskengine consumes what this plugin produces. sso and social are usually where the transmitters on the other end come from.

Documentation

Overview

Package sharedsignals implements the OpenID Shared Signals Framework and CAEP. It receives Security Event Tokens from an upstream identity provider and turns them into session revocations and durable risk signals.

Index

Constants

View Source
const (
	ActionRevokeAll     = "revoke_all"
	ActionRevokeSession = "revoke_session"
	ActionSignal        = "signal"
	ActionLog           = "log"
	ActionNone          = "none"
)

Actions an event can produce.

View Source
const (
	StatusEnabled  = "enabled"
	StatusPaused   = "paused"
	StatusDisabled = "disabled"
)

Stream status values, matching the SSF stream status vocabulary.

View Source
const (
	EnforcementEnforce = "enforce"
	EnforcementObserve = "observe"
)

Enforcement modes. Observe records what would have happened without doing it.

View Source
const (
	OutcomePending    = "pending"
	OutcomeApplied    = "applied"
	OutcomeIgnored    = "ignored"
	OutcomeUnresolved = "unresolved"
	OutcomeRejected   = "rejected"
)

Received-event outcomes.

View Source
const (
	SourceSSO    = "sso"
	SourceSocial = "social"
	SourceSCIM   = "scim"
	SourceManual = "manual"
)

Subject link sources.

View Source
const (
	DefaultReceivedEventLimit = 50
	MaxReceivedEventLimit     = 500
)

Row limits for ListReceivedEvents. The audit trail grows without bound -- a busy stream writes a row per delivery -- so a caller that names no limit gets a page, not the table, and one that names an enormous limit does not get to turn a dashboard request into a full scan.

Variables

View Source
var (
	PostgresMigrations = migrate.NewGroup("authsome-sharedsignals", migrate.DependsOn("authsome"))
	SqliteMigrations   = migrate.NewGroup("authsome-sharedsignals", migrate.DependsOn("authsome"))
	MongoMigrations    = migrate.NewGroup("authsome-sharedsignals", migrate.DependsOn("authsome"))
)

Migration groups, one per driver. All depend on the core authsome group because the stream tables reference authsome_apps.

View Source
var (
	// SettingEnabled turns the receiver on and off without deleting streams.
	SettingEnabled = settings.Define("sharedsignals.enabled", true,
		settings.WithDisplayName("Shared Signals Enabled"),
		settings.WithDescription("Accept inbound CAEP events from configured streams"),
		settings.WithCategory("Shared Signals"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithEnforceable(),
		settings.WithHelpText("Turn the receiver off to stop acting on inbound events without deleting any stream"),
		settings.WithOrder(10),
	)

	// SettingSignalTTLHours is how long a received signal keeps affecting risk.
	SettingSignalTTLHours = settings.Define("sharedsignals.signal_ttl_hours", 24,
		settings.WithDisplayName("Signal TTL (hours)"),
		settings.WithDescription("How long a received CAEP signal keeps influencing the risk score"),
		settings.WithCategory("Shared Signals"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Min: intPtr(1), Max: intPtr(720)}),
		settings.WithHelpText("Signals decay to zero over this window. Default: 24"),
		settings.WithOrder(20),
	)

	// SettingMaxActionsPerHour is the circuit breaker default for new streams.
	SettingMaxActionsPerHour = settings.Define("sharedsignals.max_actions_per_hour", 100,
		settings.WithDisplayName("Max Actions Per Hour"),
		settings.WithDescription("Actions one stream may take in an hour before it is paused"),
		settings.WithCategory("Shared Signals"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Min: intPtr(1), Max: intPtr(100000)}),
		settings.WithHelpText("A transmitter that crosses this is paused and an alert is raised"),
		settings.WithOrder(30),
	)

	// SettingCAEPSignalWeight is the weight the risk engine gives our signals.
	SettingCAEPSignalWeight = settings.Define("sharedsignals.risk_weight", 2,
		settings.WithDisplayName("Risk Weight"),
		settings.WithDescription("Weight the risk engine applies to CAEP signals"),
		settings.WithCategory("Shared Signals"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Min: intPtr(1), Max: intPtr(10)}),
		settings.WithHelpText("A CAEP event is higher confidence than an IP heuristic. Default: 2"),
		settings.WithOrder(40),
	)

	// SettingMaxRiskScore caps what a CAEP signal contributes to the score.
	SettingMaxRiskScore = settings.Define("sharedsignals.max_risk_score", 84,
		settings.WithDisplayName("Max Risk Score"),
		settings.WithDescription("Highest score a Shared Signals event may contribute"),
		settings.WithCategory("Shared Signals"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Min: intPtr(1), Max: intPtr(100)}),
		settings.WithHelpText("84 keeps a confirmed compromise in the challenge band. Raise to 100 to block the sign-in outright"),
		settings.WithOrder(50),
	)
)
View Source
var (
	// ErrNotFound is returned when a row does not exist.
	ErrNotFound = errors.New("sharedsignals: not found")
	// ErrDuplicateJTI is returned when a (stream_id, jti, event_type)
	// triple already exists. Callers treat this as a replay and answer 202,
	// so it must be distinguishable from every other write failure.
	//
	// The key is the triple, not just (stream_id, jti), because RFC 8417
	// keys a SET's `events` object by event type URI, so a single delivery
	// carries at most one event of a given type but may legitimately carry
	// several different types under the same jti. Keying on (stream_id,
	// jti) alone would make the second event in a multi-event SET collide
	// with the row the first one just inserted, on the very first delivery.
	ErrDuplicateJTI = errors.New("sharedsignals: duplicate jti")
)

Store errors.

Functions

func HashSecret

func HashSecret(raw string) string

HashSecret returns the hex SHA-256 of a push secret. Only the hash is stored, so a database copy does not yield a working push endpoint.

func NewPushSecret

func NewPushSecret() (raw, hash string, err error)

NewPushSecret mints a URL-safe random secret and its hash. The plaintext is shown to the operator once at stream creation and never persisted.

Types

type Config

type Config struct {
	// Audience is the aud value inbound SETs must carry. Streams inherit it
	// at creation when they do not set their own.
	Audience string

	// SignalTTL is how long a stored signal stays active.
	SignalTTL time.Duration

	// MaxActionsPerHour is the circuit breaker default for new streams.
	MaxActionsPerHour int

	// ClockSkew is the tolerance for an iat slightly in the future.
	ClockSkew time.Duration

	// MaxSETAge is how far in the past an iat may be. Transmitters retry for
	// a long time, so this is generous.
	MaxSETAge time.Duration

	// MaxBodyBytes bounds the push request body.
	MaxBodyBytes int64

	// MaxRiskScore caps the score this plugin hands to the risk engine.
	//
	// severityFor scores a session-revoked event at 100, which is honest: an
	// identity provider that watched an account be taken over is as certain as
	// a signal gets, and that number is what the stored signal keeps for
	// forensics. But a single contributor's score becomes the whole composite,
	// and riskengine blocks at 85 by default, so handing 100 straight through
	// refused the sign-in outright rather than stepping it up. A user with
	// correct credentials and a correct second factor could not get in.
	//
	// The default of 84 sits one below that threshold, so a CAEP signal
	// challenges. Raise it to 100 if you would rather a confirmed compromise
	// bar the door; the number is a policy statement, not a measurement.
	MaxRiskScore int

	// KeyRefreshInterval is how often cached JWKS entries are re-fetched in
	// the background. A value of zero takes the default; a negative value
	// turns the ticker off entirely, for an embedder that does not want a
	// background goroutine and accepts that key freshness then depends
	// wholly on inbound traffic.
	KeyRefreshInterval time.Duration
}

Config configures the plugin. Every field has a sane default.

type CreateStreamRequest

type CreateStreamRequest struct {
	Name                  string            `json:"name"`
	Issuer                string            `json:"issuer"`
	JWKSURI               string            `json:"jwks_uri"`
	Audience              string            `json:"audience,omitempty"`
	AllowedEventTypes     []string          `json:"allowed_event_types,omitempty"`
	AllowedSubjectFormats []string          `json:"allowed_subject_formats,omitempty"`
	VerifiedDomains       []string          `json:"verified_domains,omitempty"`
	ActionOverrides       map[string]string `json:"action_overrides,omitempty"`
	EnforcementMode       string            `json:"enforcement_mode,omitempty"`
	MaxActionsPerHour     int               `json:"max_actions_per_hour,omitempty"`
}

CreateStreamRequest registers an identity provider we will accept events from.

type CreateStreamResponse

type CreateStreamResponse struct {
	Stream StreamView `json:"stream"`
	// PushURLPath is the secret segment in the push URL.
	PushURLPath string `json:"push_url_path"`
	// PushToken is the bearer token the transmitter must send.
	PushToken string `json:"push_token"`
	// PushURL is the full URL to hand to the transmitter.
	PushURL string `json:"push_url"`
}

CreateStreamResponse carries the two secrets. This is the only time either is readable; the store keeps hashes.

type InboundStream

type InboundStream struct {
	ID    id.SSFStreamID
	AppID id.AppID
	EnvID id.EnvironmentID
	Name  string

	// Issuer is the exact iss value a SET must carry.
	Issuer string
	// Audience is the aud value a SET must include.
	Audience string
	// JWKSURI is where this stream's verification keys live.
	JWKSURI string

	// PushPathHash is SHA-256 of the secret URL segment. The plaintext is
	// shown once at creation and never stored.
	PushPathHash string
	// PushTokenHash is SHA-256 of the bearer token the transmitter must send.
	PushTokenHash string

	AllowedEventTypes     []string
	AllowedSubjectFormats []string
	VerifiedDomains       []string
	ActionOverrides       map[string]string

	EnforcementMode   string
	Status            string
	MaxActionsPerHour int

	// PendingVerifyState is the state we sent to the transmitter's
	// verification endpoint and expect echoed back in a verification event.
	PendingVerifyState string
	LastVerifiedAt     *time.Time

	CreatedAt time.Time
	UpdatedAt time.Time
}

InboundStream is one identity provider we accept events from. Everything the receiver trusts about a SET comes from this row, never from the token.

type ListStreamsResponse

type ListStreamsResponse struct {
	Streams []StreamView `json:"streams"`
}

ListStreamsResponse is the list payload.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is an in-memory Store, used in tests and in standalone mode when no database is configured.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore builds an empty in-memory store.

func (*MemoryStore) CountEventsSince

func (m *MemoryStore) CountEventsSince(_ context.Context, streamID id.SSFStreamID,
	since time.Time) (int, error)

func (*MemoryStore) CreateInboundStream

func (m *MemoryStore) CreateInboundStream(_ context.Context, s *InboundStream) error

func (*MemoryStore) CreateSignal

func (m *MemoryStore) CreateSignal(_ context.Context, s *Signal) error

func (*MemoryStore) DeleteInboundStream

func (m *MemoryStore) DeleteInboundStream(_ context.Context, streamID id.SSFStreamID) error

func (*MemoryStore) DeleteReceivedEvent

func (m *MemoryStore) DeleteReceivedEvent(_ context.Context, eventID id.SSFEventID) error

DeleteReceivedEvent removes a row by ID. Events are keyed in storage by (stream_id, jti, event_type), not by ID, so this scans -- MemoryStore backs tests and standalone mode, never a production hot path.

func (*MemoryStore) GetInboundStream

func (m *MemoryStore) GetInboundStream(_ context.Context, streamID id.SSFStreamID) (*InboundStream, error)

func (*MemoryStore) GetInboundStreamByPushPathHash

func (m *MemoryStore) GetInboundStreamByPushPathHash(_ context.Context, hash string) (*InboundStream, error)

func (*MemoryStore) GetReceivedEvent

func (m *MemoryStore) GetReceivedEvent(ctx context.Context, appID id.AppID,
	eventID id.SSFEventID) (*ReceivedEvent, error)

GetReceivedEvent loads one audit row by ID and confirms it belongs to appID. Events are keyed in storage by (stream_id, jti, event_type), not by ID, so this scans -- MemoryStore backs tests and standalone mode, never a production hot path.

func (m *MemoryStore) GetSubjectLink(_ context.Context, appID id.AppID,
	envID id.EnvironmentID, issuer, subject string) (*SubjectLink, error)

func (*MemoryStore) InsertReceivedEvent

func (m *MemoryStore) InsertReceivedEvent(_ context.Context, e *ReceivedEvent) error

func (*MemoryStore) ListActiveSignals

func (m *MemoryStore) ListActiveSignals(_ context.Context, appID id.AppID,
	envID id.EnvironmentID, userID id.UserID, now time.Time) ([]*Signal, error)

func (*MemoryStore) ListInboundStreams

func (m *MemoryStore) ListInboundStreams(_ context.Context, appID id.AppID) ([]*InboundStream, error)

func (*MemoryStore) ListReceivedEvents

func (m *MemoryStore) ListReceivedEvents(ctx context.Context, appID id.AppID,
	f ReceivedEventFilter) ([]*ReceivedEvent, error)

ListReceivedEvents returns one stream's audit rows newest first.

func (*MemoryStore) UpdateInboundStream

func (m *MemoryStore) UpdateInboundStream(_ context.Context, s *InboundStream) error

func (*MemoryStore) UpdateReceivedEvent

func (m *MemoryStore) UpdateReceivedEvent(_ context.Context, e *ReceivedEvent) error
func (m *MemoryStore) UpsertSubjectLink(_ context.Context, l *SubjectLink) error

type MongoStore

type MongoStore struct {
	// contains filtered or unexported fields
}

MongoStore implements Store on MongoDB.

func NewMongoStore

func NewMongoStore(db *grove.DB) *MongoStore

NewMongoStore builds a MongoDB-backed store.

func (*MongoStore) CountEventsSince

func (s *MongoStore) CountEventsSince(ctx context.Context,
	streamID id.SSFStreamID, since time.Time) (int, error)

func (*MongoStore) CreateInboundStream

func (s *MongoStore) CreateInboundStream(ctx context.Context, in *InboundStream) error

func (*MongoStore) CreateSignal

func (s *MongoStore) CreateSignal(ctx context.Context, sig *Signal) error

func (*MongoStore) DeleteInboundStream

func (s *MongoStore) DeleteInboundStream(ctx context.Context, streamID id.SSFStreamID) error

func (*MongoStore) DeleteReceivedEvent

func (s *MongoStore) DeleteReceivedEvent(ctx context.Context, eventID id.SSFEventID) error

func (*MongoStore) GetInboundStream

func (s *MongoStore) GetInboundStream(ctx context.Context, streamID id.SSFStreamID) (*InboundStream, error)

func (*MongoStore) GetInboundStreamByPushPathHash

func (s *MongoStore) GetInboundStreamByPushPathHash(ctx context.Context, hash string) (*InboundStream, error)

func (*MongoStore) GetReceivedEvent

func (s *MongoStore) GetReceivedEvent(ctx context.Context, appID id.AppID,
	eventID id.SSFEventID) (*ReceivedEvent, error)

GetReceivedEvent loads one audit row by ID and confirms it belongs to appID. The document carries no app_id, so the scope comes from the stream it arrived on.

func (s *MongoStore) GetSubjectLink(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, issuer, subject string) (*SubjectLink, error)

func (*MongoStore) InsertReceivedEvent

func (s *MongoStore) InsertReceivedEvent(ctx context.Context, e *ReceivedEvent) error

func (*MongoStore) ListActiveSignals

func (s *MongoStore) ListActiveSignals(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, userID id.UserID, now time.Time) ([]*Signal, error)

func (*MongoStore) ListInboundStreams

func (s *MongoStore) ListInboundStreams(ctx context.Context, appID id.AppID) ([]*InboundStream, error)

func (*MongoStore) ListReceivedEvents

func (s *MongoStore) ListReceivedEvents(ctx context.Context, appID id.AppID,
	f ReceivedEventFilter) ([]*ReceivedEvent, error)

ListReceivedEvents returns one stream's audit rows newest first. The (stream_id, received_at DESC) index from the create migration is exactly this query's access path.

func (*MongoStore) UpdateInboundStream

func (s *MongoStore) UpdateInboundStream(ctx context.Context, in *InboundStream) error

func (*MongoStore) UpdateReceivedEvent

func (s *MongoStore) UpdateReceivedEvent(ctx context.Context, e *ReceivedEvent) error
func (s *MongoStore) UpsertSubjectLink(ctx context.Context, l *SubjectLink) error

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin receives Shared Signals events and turns them into session revocations and durable risk signals.

func New

func New(cfg ...Config) *Plugin

New builds the plugin. Config is optional.

func (*Plugin) CreateStream

func (p *Plugin) CreateStream(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, req CreateStreamRequest) (*CreateStreamResponse, error)

CreateStream registers a stream and mints its two secrets.

func (*Plugin) DeclareSettings

func (p *Plugin) DeclareSettings(m *settings.Manager) error

DeclareSettings implements plugin.SettingsProvider.

func (*Plugin) EvaluateRisk

func (p *Plugin) EvaluateRisk(ctx context.Context, req *riskengine.RiskRequest) (*riskengine.RiskSignal, error)

EvaluateRisk replays stored signals at sign-in time. This is how an event that arrived at 02:00, when nobody was signing in, reaches the 08:00 login that cares about it. A high score crosses riskengine's medium threshold and its decision becomes "challenge", which is how step-up is expressed without inventing a second mechanism.

func (*Plugin) LinkSubject

func (p *Plugin) LinkSubject(ctx context.Context, appID id.AppID, envID id.EnvironmentID,
	issuer, subject string, userID id.UserID, source string) error

LinkSubject records that (issuer, subject) is this user, so a later CAEP event naming that pair resolves. Calling it repeatedly is safe: the store upserts on the tuple.

func (*Plugin) MigrationGroups

func (p *Plugin) MigrationGroups(driverName string) []*migrate.Group

MigrationGroups implements plugin.MigrationProvider.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns the plugin name.

func (*Plugin) OnInit

func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error

OnInit captures engine references and picks a store for the driver in use.

func (*Plugin) OnShutdown

func (p *Plugin) OnShutdown(_ context.Context) error

OnShutdown stops the background JWKS refresh and waits for any round already in flight, so the process does not exit with an outbound fetch still open against an IdP.

func (*Plugin) RegisterRoutes

func (p *Plugin) RegisterRoutes(router forge.Router) error

RegisterRoutes implements plugin.RouteProvider. The receiver endpoint lands in receiver.go and the admin CRUD in admin.go.

func (*Plugin) SetStore

func (p *Plugin) SetStore(s Store)

SetStore overrides the backing store. Tests use it; production wiring goes through OnInit.

func (*Plugin) UpdateStream

func (p *Plugin) UpdateStream(ctx context.Context, streamID id.SSFStreamID,
	req UpdateStreamRequest) (*StreamView, error)

UpdateStream changes the mutable fields of a stream.

type PostgresStore

type PostgresStore struct {
	// contains filtered or unexported fields
}

PostgresStore implements Store on the Grove ORM with PostgreSQL.

func NewPostgresStore

func NewPostgresStore(db *grove.DB) *PostgresStore

NewPostgresStore builds a PostgreSQL-backed store.

func (*PostgresStore) CountEventsSince

func (s *PostgresStore) CountEventsSince(ctx context.Context,
	streamID id.SSFStreamID, since time.Time) (int, error)

func (*PostgresStore) CreateInboundStream

func (s *PostgresStore) CreateInboundStream(ctx context.Context, in *InboundStream) error

func (*PostgresStore) CreateSignal

func (s *PostgresStore) CreateSignal(ctx context.Context, sig *Signal) error

func (*PostgresStore) DeleteInboundStream

func (s *PostgresStore) DeleteInboundStream(ctx context.Context, streamID id.SSFStreamID) error

func (*PostgresStore) DeleteReceivedEvent

func (s *PostgresStore) DeleteReceivedEvent(ctx context.Context, eventID id.SSFEventID) error

func (*PostgresStore) GetInboundStream

func (s *PostgresStore) GetInboundStream(ctx context.Context, streamID id.SSFStreamID) (*InboundStream, error)

func (*PostgresStore) GetInboundStreamByPushPathHash

func (s *PostgresStore) GetInboundStreamByPushPathHash(ctx context.Context, hash string) (*InboundStream, error)

func (*PostgresStore) GetReceivedEvent

func (s *PostgresStore) GetReceivedEvent(ctx context.Context, appID id.AppID,
	eventID id.SSFEventID) (*ReceivedEvent, error)

GetReceivedEvent loads one audit row by ID and confirms it belongs to appID. The row itself carries no app_id, so the scope comes from the stream it arrived on.

func (s *PostgresStore) GetSubjectLink(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, issuer, subject string) (*SubjectLink, error)

func (*PostgresStore) InsertReceivedEvent

func (s *PostgresStore) InsertReceivedEvent(ctx context.Context, e *ReceivedEvent) error

func (*PostgresStore) ListActiveSignals

func (s *PostgresStore) ListActiveSignals(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, userID id.UserID, now time.Time) ([]*Signal, error)

func (*PostgresStore) ListInboundStreams

func (s *PostgresStore) ListInboundStreams(ctx context.Context, appID id.AppID) ([]*InboundStream, error)

func (*PostgresStore) ListReceivedEvents

func (s *PostgresStore) ListReceivedEvents(ctx context.Context, appID id.AppID,
	f ReceivedEventFilter) ([]*ReceivedEvent, error)

ListReceivedEvents returns one stream's audit rows newest first. The (stream_id, received_at DESC) index from the create migration is exactly this query's access path.

func (*PostgresStore) UpdateInboundStream

func (s *PostgresStore) UpdateInboundStream(ctx context.Context, in *InboundStream) error

func (*PostgresStore) UpdateReceivedEvent

func (s *PostgresStore) UpdateReceivedEvent(ctx context.Context, e *ReceivedEvent) error
func (s *PostgresStore) UpsertSubjectLink(ctx context.Context, l *SubjectLink) error

type ReceivedEvent

type ReceivedEvent struct {
	ID             id.SSFEventID
	StreamID       id.SSFStreamID
	JTI            string
	EventType      string
	SubjectJSON    string
	ResolvedUserID id.UserID
	Outcome        string
	ActionTaken    string
	Error          string
	ReceivedAt     time.Time
}

ReceivedEvent is both the replay guard and the audit trail for one SET.

type ReceivedEventFilter

type ReceivedEventFilter struct {
	StreamID id.SSFStreamID
	// Since and Until bound received_at, half-open: [Since, Until). Either
	// may be zero, meaning unbounded on that side.
	Since time.Time
	Until time.Time
	// Limit caps the rows returned. Zero or less means
	// DefaultReceivedEventLimit; anything above MaxReceivedEventLimit is
	// clamped down to it.
	Limit int
}

ReceivedEventFilter narrows a ListReceivedEvents query. A received event carries no app_id of its own -- the stream it arrived on is what binds it to a tenant -- so StreamID is mandatory and every backend resolves the stream before it reads a single row.

type Resolution

type Resolution struct {
	UserID id.UserID
	// SessionID is set only for a complex subject whose session member both
	// parsed as a valid session ID AND was verified to belong to UserID. A
	// session member that fails either check rejects the whole event rather
	// than being dropped, so callers may treat a non-nil SessionID here as
	// already scoped to UserID without re-checking ownership themselves.
	SessionID id.SessionID
	Outcome   string
}

Resolution is the outcome of turning a subject identifier into one of our users. Outcome is OutcomeApplied when UserID is set, OutcomeUnresolved when the identifier was acceptable but named nobody we know, and OutcomeRejected when the identifier was not one this stream may assert.

type Signal

type Signal struct {
	ID        id.SSFSignalID
	AppID     id.AppID
	EnvID     id.EnvironmentID
	UserID    id.UserID
	StreamID  id.SSFStreamID
	EventType string
	Severity  int
	Reason    string
	EventAt   time.Time
	ExpiresAt time.Time
	CreatedAt time.Time
}

Signal is durable risk state. An event that arrives while nobody is signing in has to survive until the sign-in that cares about it.

type SqliteStore

type SqliteStore struct {
	// contains filtered or unexported fields
}

SqliteStore implements Store on the Grove ORM with SQLite.

func NewSqliteStore

func NewSqliteStore(db *grove.DB) *SqliteStore

NewSqliteStore builds a SQLite-backed store.

func (*SqliteStore) CountEventsSince

func (s *SqliteStore) CountEventsSince(ctx context.Context,
	streamID id.SSFStreamID, since time.Time) (int, error)

func (*SqliteStore) CreateInboundStream

func (s *SqliteStore) CreateInboundStream(ctx context.Context, in *InboundStream) error

func (*SqliteStore) CreateSignal

func (s *SqliteStore) CreateSignal(ctx context.Context, sig *Signal) error

func (*SqliteStore) DeleteInboundStream

func (s *SqliteStore) DeleteInboundStream(ctx context.Context, streamID id.SSFStreamID) error

func (*SqliteStore) DeleteReceivedEvent

func (s *SqliteStore) DeleteReceivedEvent(ctx context.Context, eventID id.SSFEventID) error

func (*SqliteStore) GetInboundStream

func (s *SqliteStore) GetInboundStream(ctx context.Context, streamID id.SSFStreamID) (*InboundStream, error)

func (*SqliteStore) GetInboundStreamByPushPathHash

func (s *SqliteStore) GetInboundStreamByPushPathHash(ctx context.Context, hash string) (*InboundStream, error)

func (*SqliteStore) GetReceivedEvent

func (s *SqliteStore) GetReceivedEvent(ctx context.Context, appID id.AppID,
	eventID id.SSFEventID) (*ReceivedEvent, error)

GetReceivedEvent loads one audit row by ID and confirms it belongs to appID. The row itself carries no app_id, so the scope comes from the stream it arrived on.

func (s *SqliteStore) GetSubjectLink(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, issuer, subject string) (*SubjectLink, error)

func (*SqliteStore) InsertReceivedEvent

func (s *SqliteStore) InsertReceivedEvent(ctx context.Context, e *ReceivedEvent) error

func (*SqliteStore) ListActiveSignals

func (s *SqliteStore) ListActiveSignals(ctx context.Context, appID id.AppID,
	envID id.EnvironmentID, userID id.UserID, now time.Time) ([]*Signal, error)

func (*SqliteStore) ListInboundStreams

func (s *SqliteStore) ListInboundStreams(ctx context.Context, appID id.AppID) ([]*InboundStream, error)

func (*SqliteStore) ListReceivedEvents

func (s *SqliteStore) ListReceivedEvents(ctx context.Context, appID id.AppID,
	f ReceivedEventFilter) ([]*ReceivedEvent, error)

ListReceivedEvents returns one stream's audit rows newest first. The (stream_id, received_at DESC) index from the create migration is exactly this query's access path.

func (*SqliteStore) UpdateInboundStream

func (s *SqliteStore) UpdateInboundStream(ctx context.Context, in *InboundStream) error

func (*SqliteStore) UpdateReceivedEvent

func (s *SqliteStore) UpdateReceivedEvent(ctx context.Context, e *ReceivedEvent) error
func (s *SqliteStore) UpsertSubjectLink(ctx context.Context, l *SubjectLink) error

type Store

type Store interface {
	CreateInboundStream(ctx context.Context, s *InboundStream) error
	GetInboundStream(ctx context.Context, streamID id.SSFStreamID) (*InboundStream, error)
	GetInboundStreamByPushPathHash(ctx context.Context, hash string) (*InboundStream, error)
	ListInboundStreams(ctx context.Context, appID id.AppID) ([]*InboundStream, error)
	UpdateInboundStream(ctx context.Context, s *InboundStream) error
	DeleteInboundStream(ctx context.Context, streamID id.SSFStreamID) error

	UpsertSubjectLink(ctx context.Context, l *SubjectLink) error
	GetSubjectLink(ctx context.Context, appID id.AppID, envID id.EnvironmentID,
		issuer, subject string) (*SubjectLink, error)

	// InsertReceivedEvent returns ErrDuplicateJTI when (stream_id, jti,
	// event_type) already exists.
	InsertReceivedEvent(ctx context.Context, e *ReceivedEvent) error
	UpdateReceivedEvent(ctx context.Context, e *ReceivedEvent) error
	// DeleteReceivedEvent removes one received-event row by ID. Its only
	// caller is the servePush pipeline undoing InsertReceivedEvent's dedupe
	// row after processing that event failed for an infrastructure reason
	// rather than a policy one -- a transmitter retry must actually be
	// reprocessed, not read back later as a replay of a delivery that was
	// never really handled.
	DeleteReceivedEvent(ctx context.Context, id id.SSFEventID) error
	// GetReceivedEvent loads one audit row by ID, scoped to appID. The
	// row itself has no app_id, so the scope is enforced through the
	// stream it arrived on; a row belonging to another tenant answers
	// ErrNotFound, never a distinguishable "forbidden".
	GetReceivedEvent(ctx context.Context, appID id.AppID,
		eventID id.SSFEventID) (*ReceivedEvent, error)
	// ListReceivedEvents returns one stream's audit rows newest first,
	// bounded by the filter's time window and row limit. A stream that
	// does not belong to appID answers ErrNotFound rather than an empty
	// list, so a probe cannot tell "not yours" from "yours but quiet".
	ListReceivedEvents(ctx context.Context, appID id.AppID,
		f ReceivedEventFilter) ([]*ReceivedEvent, error)
	// CountEventsSince counts every event RECORDED for a stream since a
	// cutoff, whatever outcome it reached. It backs the circuit breaker.
	//
	// It deliberately does not filter on action_taken. Counting only the
	// events that produced an action left the entire signal-only half of
	// the matrix outside the breaker: an authentic but hostile transmitter
	// could push unlimited risk-level-change events at HIGH, raising every
	// user's risk score, while the counter stayed at zero. The breaker
	// exists to bound authentic traffic, so it has to count all of it.
	CountEventsSince(ctx context.Context, streamID id.SSFStreamID, since time.Time) (int, error)

	CreateSignal(ctx context.Context, s *Signal) error
	ListActiveSignals(ctx context.Context, appID id.AppID, envID id.EnvironmentID,
		userID id.UserID, now time.Time) ([]*Signal, error)
}

Store persists everything the receiver needs.

type StreamView

type StreamView struct {
	ID                    string            `json:"id"`
	Name                  string            `json:"name"`
	Issuer                string            `json:"issuer"`
	Audience              string            `json:"audience"`
	JWKSURI               string            `json:"jwks_uri"`
	AllowedEventTypes     []string          `json:"allowed_event_types"`
	AllowedSubjectFormats []string          `json:"allowed_subject_formats"`
	VerifiedDomains       []string          `json:"verified_domains"`
	ActionOverrides       map[string]string `json:"action_overrides"`
	EnforcementMode       string            `json:"enforcement_mode"`
	Status                string            `json:"status"`
	MaxActionsPerHour     int               `json:"max_actions_per_hour"`
	LastVerifiedAt        *time.Time        `json:"last_verified_at,omitempty"`
	CreatedAt             time.Time         `json:"created_at"`
	UpdatedAt             time.Time         `json:"updated_at"`
}

StreamView is the read model. It deliberately carries no secret and no hash of one.

type SubjectLink struct {
	ID         id.SSFLinkID
	AppID      id.AppID
	EnvID      id.EnvironmentID
	Issuer     string
	Subject    string
	UserID     id.UserID
	Source     string
	CreatedAt  time.Time
	LastSeenAt time.Time
}

SubjectLink binds an upstream (issuer, subject) pair to an authsome user. This is what makes the iss_sub subject format resolvable.

type SubjectLinker

type SubjectLinker interface {
	LinkSubject(ctx context.Context, appID id.AppID, envID id.EnvironmentID,
		issuer, subject string, userID id.UserID, source string) error
}

SubjectLinker is implemented by this plugin so other plugins can record the upstream identity they just authenticated without importing the concrete type. Callers reach it through engine.Plugin("sharedsignals") and a type assertion, the same way risk contributors are wired.

type UpdateStreamRequest

type UpdateStreamRequest struct {
	Name                  *string           `json:"name,omitempty"`
	Status                *string           `json:"status,omitempty"`
	EnforcementMode       *string           `json:"enforcement_mode,omitempty"`
	MaxActionsPerHour     *int              `json:"max_actions_per_hour,omitempty"`
	JWKSURI               *string           `json:"jwks_uri,omitempty"`
	AllowedEventTypes     []string          `json:"allowed_event_types,omitempty"`
	AllowedSubjectFormats []string          `json:"allowed_subject_formats,omitempty"`
	VerifiedDomains       []string          `json:"verified_domains,omitempty"`
	ActionOverrides       map[string]string `json:"action_overrides,omitempty"`
}

UpdateStreamRequest changes the mutable parts of a stream. The two push secrets are not among them; rotate by creating a new stream.

Directories

Path Synopsis
Package caep implements the OpenID CAEP event types and the RFC 9493 subject identifiers they carry.
Package caep implements the OpenID CAEP event types and the RFC 9493 subject identifiers they carry.
Package jwksclient fetches and caches JSON Web Key Sets for inbound token verification.
Package jwksclient fetches and caches JSON Web Key Sets for inbound token verification.
Package setjwt parses and validates RFC 8417 Security Event Tokens.
Package setjwt parses and validates RFC 8417 Security Event Tokens.
Package ssftest provides a backend-agnostic conformance suite for the sharedsignals.Store interface.
Package ssftest provides a backend-agnostic conformance suite for the sharedsignals.Store interface.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL