diff --git a/api/docs/docs.go b/api/docs/docs.go index 91df3431..6684dc2f 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -456,6 +456,166 @@ const docTemplate = `{ } } }, + "/policies": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "List media-auth policies", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + } + }, + "/policies/{code}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Get media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Create or update media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + }, + { + "description": "Policy configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.Policy" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Delete media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + } + }, "/readyz": { "get": { "description": "Returns 200 when the server accepts traffic (basic check).", @@ -1941,8 +2101,8 @@ const docTemplate = `{ "name": { "type": "string" }, - "playback_auth": { - "description": "PlaybackAuth overrides the global media-auth default policy for this\nstream: \"public\" (no token) or \"token\" (signed token required). Empty\ninherits auth.media.default_policy. Static rules (IP/country/UA/domains)\nremain global. See internal/mediaauth.", + "playback_policy": { + "description": "PlaybackPolicy binds this stream to a named media-auth Policy by its\ncode (see domain.Policy). Empty means no policy — the stream is public\n(allow-all). The referenced policy carries the full rule set (token\nrequirement + secret, IP/country/UA/domain allow-deny lists). See\ninternal/mediaauth.", "type": "string" }, "protocols": { @@ -2108,9 +2268,6 @@ const docTemplate = `{ "properties": { "api": { "$ref": "#/definitions/config.APIAuthConfig" - }, - "media": { - "$ref": "#/definitions/config.MediaAuthConfig" } } }, @@ -2234,65 +2391,6 @@ const docTemplate = `{ } } }, - "config.MediaAuthConfig": { - "type": "object", - "properties": { - "allow_countries": { - "type": "array", - "items": { - "type": "string" - } - }, - "allow_ips": { - "description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).", - "type": "array", - "items": { - "type": "string" - } - }, - "allow_user_agents": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowed_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "default_policy": { - "description": "DefaultPolicy is \"public\" (no token needed) or \"token\" (signed token\nrequired) for streams that don't set their own. Empty = public.", - "type": "string" - }, - "deny_countries": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny_ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny_user_agents": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "token_secret": { - "description": "TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback\ntokens. Clients (your app) mint tokens with the same secret — the server\nnever issues them. Required when any stream's effective policy is \"token\".\nSee internal/mediaauth.SignToken for the canonical token format.", - "type": "string" - } - } - }, "config.PublisherConfig": { "type": "object", "properties": { @@ -2544,8 +2642,6 @@ const docTemplate = `{ "domain.EventType": { "type": "string", "enum": [ - "session.opened", - "session.closed", "stream.created", "stream.updated", "stream.started", @@ -2578,8 +2674,13 @@ const docTemplate = `{ "template.created", "template.updated", "template.deleted", + "policy.created", + "policy.updated", + "policy.deleted", "stream.runtime_created", - "stream.runtime_expired" + "stream.runtime_expired", + "session.opened", + "session.closed" ], "x-enum-comments": { "EventDVRSegmentPruned": "retention loop deleted an aged-out segment", @@ -2596,8 +2697,6 @@ const docTemplate = `{ "EventStreamUpdated": "PUT /streams/{code} on existing record" }, "x-enum-descriptions": [ - "", - "", "", "PUT /streams/{code} on existing record", "", @@ -2631,11 +2730,14 @@ const docTemplate = `{ "", "", "", + "", + "", + "", + "", + "", "" ], "x-enum-varnames": [ - "EventSessionOpened", - "EventSessionClosed", "EventStreamCreated", "EventStreamUpdated", "EventStreamStarted", @@ -2668,8 +2770,13 @@ const docTemplate = `{ "EventTemplateCreated", "EventTemplateUpdated", "EventTemplateDeleted", + "EventPolicyCreated", + "EventPolicyUpdated", + "EventPolicyDeleted", "EventStreamRuntimeCreated", - "EventStreamRuntimeExpired" + "EventStreamRuntimeExpired", + "EventSessionOpened", + "EventSessionClosed" ] }, "domain.GlobalConfig": { @@ -3061,6 +3168,73 @@ const docTemplate = `{ } } }, + "domain.Policy": { + "type": "object", + "properties": { + "allow_countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_ips": { + "description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).", + "type": "array", + "items": { + "type": "string" + } + }, + "allow_user_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "code": { + "description": "Code is the unique key chosen by the operator.", + "type": "string" + }, + "deny_countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_user_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "name": { + "description": "Name and Description are operator-facing metadata.", + "type": "string" + }, + "require_token": { + "description": "RequireToken makes a valid signed playback token mandatory. The server\nonly VERIFIES tokens; clients mint them with TokenSecret — see\ninternal/mediaauth.SignToken for the canonical token format.", + "type": "boolean" + }, + "token_secret": { + "description": "TokenSecret is this policy's HMAC-SHA256 verification key. Required when\nRequireToken is true. Each policy owns its own secret so revoking one\npolicy's key never affects another.", + "type": "string" + } + } + }, "domain.PushDestination": { "type": "object", "properties": { @@ -3308,8 +3482,8 @@ const docTemplate = `{ "description": "Name and Description are template-level metadata. Name surfaces in\nthe API for human-readable lists; Description carries the rationale\nbehind the template's settings. Streams inheriting this template\nkeep their own Name / Description fields — the template metadata is\nfor operator-facing tooling, not for downstream consumers.", "type": "string" }, - "playback_auth": { - "description": "PlaybackAuth is the media-auth policy (\"public\"/\"token\") inherited by\nstreams referencing this template. Empty = inherit global default.", + "playback_policy": { + "description": "PlaybackPolicy is the media-auth Policy code inherited by streams\nreferencing this template (see domain.Policy). Empty = no policy\n(inheriting streams stay public unless they set their own).", "type": "string" }, "prefixes": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index b6d6fa5e..73b229da 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -449,6 +449,166 @@ } } }, + "/policies": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "List media-auth policies", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + } + }, + "/policies/{code}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Get media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Create or update media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + }, + { + "description": "Policy configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.Policy" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "policies" + ], + "summary": "Delete media-auth policy", + "parameters": [ + { + "type": "string", + "description": "Policy code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/apidocs.ErrorBody" + } + } + } + } + }, "/readyz": { "get": { "description": "Returns 200 when the server accepts traffic (basic check).", @@ -1934,8 +2094,8 @@ "name": { "type": "string" }, - "playback_auth": { - "description": "PlaybackAuth overrides the global media-auth default policy for this\nstream: \"public\" (no token) or \"token\" (signed token required). Empty\ninherits auth.media.default_policy. Static rules (IP/country/UA/domains)\nremain global. See internal/mediaauth.", + "playback_policy": { + "description": "PlaybackPolicy binds this stream to a named media-auth Policy by its\ncode (see domain.Policy). Empty means no policy — the stream is public\n(allow-all). The referenced policy carries the full rule set (token\nrequirement + secret, IP/country/UA/domain allow-deny lists). See\ninternal/mediaauth.", "type": "string" }, "protocols": { @@ -2101,9 +2261,6 @@ "properties": { "api": { "$ref": "#/definitions/config.APIAuthConfig" - }, - "media": { - "$ref": "#/definitions/config.MediaAuthConfig" } } }, @@ -2227,65 +2384,6 @@ } } }, - "config.MediaAuthConfig": { - "type": "object", - "properties": { - "allow_countries": { - "type": "array", - "items": { - "type": "string" - } - }, - "allow_ips": { - "description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).", - "type": "array", - "items": { - "type": "string" - } - }, - "allow_user_agents": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowed_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "default_policy": { - "description": "DefaultPolicy is \"public\" (no token needed) or \"token\" (signed token\nrequired) for streams that don't set their own. Empty = public.", - "type": "string" - }, - "deny_countries": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny_ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny_user_agents": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "token_secret": { - "description": "TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback\ntokens. Clients (your app) mint tokens with the same secret — the server\nnever issues them. Required when any stream's effective policy is \"token\".\nSee internal/mediaauth.SignToken for the canonical token format.", - "type": "string" - } - } - }, "config.PublisherConfig": { "type": "object", "properties": { @@ -2537,8 +2635,6 @@ "domain.EventType": { "type": "string", "enum": [ - "session.opened", - "session.closed", "stream.created", "stream.updated", "stream.started", @@ -2571,8 +2667,13 @@ "template.created", "template.updated", "template.deleted", + "policy.created", + "policy.updated", + "policy.deleted", "stream.runtime_created", - "stream.runtime_expired" + "stream.runtime_expired", + "session.opened", + "session.closed" ], "x-enum-comments": { "EventDVRSegmentPruned": "retention loop deleted an aged-out segment", @@ -2589,8 +2690,6 @@ "EventStreamUpdated": "PUT /streams/{code} on existing record" }, "x-enum-descriptions": [ - "", - "", "", "PUT /streams/{code} on existing record", "", @@ -2624,11 +2723,14 @@ "", "", "", + "", + "", + "", + "", + "", "" ], "x-enum-varnames": [ - "EventSessionOpened", - "EventSessionClosed", "EventStreamCreated", "EventStreamUpdated", "EventStreamStarted", @@ -2661,8 +2763,13 @@ "EventTemplateCreated", "EventTemplateUpdated", "EventTemplateDeleted", + "EventPolicyCreated", + "EventPolicyUpdated", + "EventPolicyDeleted", "EventStreamRuntimeCreated", - "EventStreamRuntimeExpired" + "EventStreamRuntimeExpired", + "EventSessionOpened", + "EventSessionClosed" ] }, "domain.GlobalConfig": { @@ -3054,6 +3161,73 @@ } } }, + "domain.Policy": { + "type": "object", + "properties": { + "allow_countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_ips": { + "description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).", + "type": "array", + "items": { + "type": "string" + } + }, + "allow_user_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "code": { + "description": "Code is the unique key chosen by the operator.", + "type": "string" + }, + "deny_countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_user_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "name": { + "description": "Name and Description are operator-facing metadata.", + "type": "string" + }, + "require_token": { + "description": "RequireToken makes a valid signed playback token mandatory. The server\nonly VERIFIES tokens; clients mint them with TokenSecret — see\ninternal/mediaauth.SignToken for the canonical token format.", + "type": "boolean" + }, + "token_secret": { + "description": "TokenSecret is this policy's HMAC-SHA256 verification key. Required when\nRequireToken is true. Each policy owns its own secret so revoking one\npolicy's key never affects another.", + "type": "string" + } + } + }, "domain.PushDestination": { "type": "object", "properties": { @@ -3301,8 +3475,8 @@ "description": "Name and Description are template-level metadata. Name surfaces in\nthe API for human-readable lists; Description carries the rationale\nbehind the template's settings. Streams inheriting this template\nkeep their own Name / Description fields — the template metadata is\nfor operator-facing tooling, not for downstream consumers.", "type": "string" }, - "playback_auth": { - "description": "PlaybackAuth is the media-auth policy (\"public\"/\"token\") inherited by\nstreams referencing this template. Empty = inherit global default.", + "playback_policy": { + "description": "PlaybackPolicy is the media-auth Policy code inherited by streams\nreferencing this template (see domain.Policy). Empty = no policy\n(inheriting streams stay public unless they set their own).", "type": "string" }, "prefixes": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb2662cb..af1de966 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -213,12 +213,13 @@ definitions: type: array name: type: string - playback_auth: + playback_policy: description: |- - PlaybackAuth overrides the global media-auth default policy for this - stream: "public" (no token) or "token" (signed token required). Empty - inherits auth.media.default_policy. Static rules (IP/country/UA/domains) - remain global. See internal/mediaauth. + PlaybackPolicy binds this stream to a named media-auth Policy by its + code (see domain.Policy). Empty means no policy — the stream is public + (allow-all). The referenced policy carries the full rule set (token + requirement + secret, IP/country/UA/domain allow-deny lists). See + internal/mediaauth. type: string protocols: allOf: @@ -338,8 +339,6 @@ definitions: properties: api: $ref: '#/definitions/config.APIAuthConfig' - media: - $ref: '#/definitions/config.MediaAuthConfig' type: object config.BufferConfig: properties: @@ -451,56 +450,6 @@ definitions: healthy primary will be falsely failed over to a lower priority. type: integer type: object - config.MediaAuthConfig: - properties: - allow_countries: - items: - type: string - type: array - allow_ips: - description: |- - Static allow/deny chain. IPs accept exact addresses or CIDR ranges; - Countries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see - SessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring; - AllowedDomains match the Referer host (exact or parent domain). - items: - type: string - type: array - allow_user_agents: - items: - type: string - type: array - allowed_domains: - items: - type: string - type: array - default_policy: - description: |- - DefaultPolicy is "public" (no token needed) or "token" (signed token - required) for streams that don't set their own. Empty = public. - type: string - deny_countries: - items: - type: string - type: array - deny_ips: - items: - type: string - type: array - deny_user_agents: - items: - type: string - type: array - enabled: - type: boolean - token_secret: - description: |- - TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback - tokens. Clients (your app) mint tokens with the same secret — the server - never issues them. Required when any stream's effective policy is "token". - See internal/mediaauth.SignToken for the canonical token format. - type: string - type: object config.PublisherConfig: properties: dash: @@ -721,8 +670,6 @@ definitions: type: object domain.EventType: enum: - - session.opened - - session.closed - stream.created - stream.updated - stream.started @@ -755,8 +702,13 @@ definitions: - template.created - template.updated - template.deleted + - policy.created + - policy.updated + - policy.deleted - stream.runtime_created - stream.runtime_expired + - session.opened + - session.closed type: string x-enum-comments: EventDVRSegmentPruned: retention loop deleted an aged-out segment @@ -774,8 +726,6 @@ definitions: EventStreamUpdated: PUT /streams/{code} on existing record x-enum-descriptions: - "" - - "" - - "" - PUT /streams/{code} on existing record - "" - "" @@ -809,9 +759,12 @@ definitions: - "" - "" - "" + - "" + - "" + - "" + - "" + - "" x-enum-varnames: - - EventSessionOpened - - EventSessionClosed - EventStreamCreated - EventStreamUpdated - EventStreamStarted @@ -844,8 +797,13 @@ definitions: - EventTemplateCreated - EventTemplateUpdated - EventTemplateDeleted + - EventPolicyCreated + - EventPolicyUpdated + - EventPolicyDeleted - EventStreamRuntimeCreated - EventStreamRuntimeExpired + - EventSessionOpened + - EventSessionClosed domain.GlobalConfig: properties: auth: @@ -1249,6 +1207,62 @@ definitions: be resolved. type: string type: object + domain.Policy: + properties: + allow_countries: + items: + type: string + type: array + allow_ips: + description: |- + Static allow/deny chain. IPs accept exact addresses or CIDR ranges; + Countries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see + SessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring; + AllowedDomains match the Referer host (exact or parent domain). + items: + type: string + type: array + allow_user_agents: + items: + type: string + type: array + allowed_domains: + items: + type: string + type: array + code: + description: Code is the unique key chosen by the operator. + type: string + deny_countries: + items: + type: string + type: array + deny_ips: + items: + type: string + type: array + deny_user_agents: + items: + type: string + type: array + description: + type: string + name: + description: Name and Description are operator-facing metadata. + type: string + require_token: + description: |- + RequireToken makes a valid signed playback token mandatory. The server + only VERIFIES tokens; clients mint them with TokenSecret — see + internal/mediaauth.SignToken for the canonical token format. + type: boolean + token_secret: + description: |- + TokenSecret is this policy's HMAC-SHA256 verification key. Required when + RequireToken is true. Each policy owns its own secret so revoking one + policy's key never affects another. + type: string + type: object domain.PushDestination: properties: comment: @@ -1464,10 +1478,11 @@ definitions: keep their own Name / Description fields — the template metadata is for operator-facing tooling, not for downstream consumers. type: string - playback_auth: + playback_policy: description: |- - PlaybackAuth is the media-auth policy ("public"/"token") inherited by - streams referencing this template. Empty = inherit global default. + PlaybackPolicy is the media-auth Policy code inherited by streams + referencing this template (see domain.Policy). Empty = no policy + (inheriting streams stay public unless they set their own). type: string prefixes: description: |- @@ -2500,6 +2515,112 @@ paths: summary: Test hook delivery tags: - hooks + /policies: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/apidocs.ErrorBody' + summary: List media-auth policies + tags: + - policies + /policies/{code}: + delete: + parameters: + - description: Policy code + in: path + name: code + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/apidocs.ErrorBody' + "409": + description: Conflict + schema: + $ref: '#/definitions/apidocs.ErrorBody' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/apidocs.ErrorBody' + summary: Delete media-auth policy + tags: + - policies + get: + parameters: + - description: Policy code + in: path + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/apidocs.ErrorBody' + summary: Get media-auth policy + tags: + - policies + post: + consumes: + - application/json + parameters: + - description: Policy code + in: path + name: code + required: true + type: string + - description: Policy configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.Policy' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/apidocs.ErrorBody' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/apidocs.ErrorBody' + summary: Create or update media-auth policy + tags: + - policies /readyz: get: description: Returns 200 when the server accepts traffic (basic check). diff --git a/cmd/server/main.go b/cmd/server/main.go index 46148f94..ca729fab 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -134,10 +134,11 @@ func run() error { // 5. Wire all services. wireServices(injector) - // 5b. Wire the media-plane playback authorizer (token / IP / country / UA / - // referer) into the publisher + API server. Returns the shared authorizer - // so the runtime config-reload path can hot-swap its rules. - mediaAuthz := wireMediaAuth(injector, gcfg) + // 5b. Wire the policy-based media-plane playback authorizer (token / IP / + // country / UA / referer) into the publisher + API server. The authorizer + // loads its policy set from the store and is hot-reloaded by the policy + // handler on CRUD — no config-reload hook needed. + wireMediaAuth(injector, gcfg) // 6. Assemble RuntimeManager deps from DI. rtm := runtime.New(ctx, runtime.Deps{ @@ -150,7 +151,6 @@ func run() error { SessionsSvc: do.MustInvoke[*sessions.Service](injector), AutoPublish: do.MustInvoke[*autopublish.Service](injector), APISrv: do.MustInvoke[*api.Server](injector), - MediaAuth: mediaAuthz, Bus: do.MustInvoke[events.Bus](injector), StreamRepo: do.MustInvoke[store.StreamRepository](injector), GlobalConfigRepo: gcRepo, @@ -200,6 +200,7 @@ func wireStorage(i *do.RootScope, cfg config.StorageConfig) error { } do.ProvideValue(i, s.Streams()) do.ProvideValue(i, s.Templates()) + do.ProvideValue(i, s.Policies()) do.ProvideValue(i, s.Recordings()) do.ProvideValue(i, s.Hooks()) do.ProvideValue(i, s.GlobalConfig()) @@ -212,6 +213,7 @@ func wireStorage(i *do.RootScope, cfg config.StorageConfig) error { } do.ProvideValue(i, s.Streams()) do.ProvideValue(i, s.Templates()) + do.ProvideValue(i, s.Policies()) do.ProvideValue(i, s.Recordings()) do.ProvideValue(i, s.Hooks()) do.ProvideValue(i, s.GlobalConfig()) @@ -320,6 +322,7 @@ func wireServices(i *do.RootScope) { // API handlers do.Provide(i, handler.NewStreamHandler) do.Provide(i, handler.NewTemplateHandler) + do.Provide(i, handler.NewPolicyHandler) do.Provide(i, handler.NewBlobTimeshiftHandler) do.Provide(i, handler.NewHookHandler) do.Provide(i, handler.NewConfigHandler) @@ -339,23 +342,27 @@ func wireServices(i *do.RootScope) { // indexed), so blocking briefly here is acceptable. // wireMediaAuth builds the shared media-plane playback authorizer and injects // it into the publisher (RTMP/SRT/RTSP play) and API server (HLS/DASH/MPEGTS). -// The country backend reuses the sessions GeoIP resolver; the per-stream policy -// is resolved from the publisher's in-memory stream table (O(1), no store hit). -func wireMediaAuth(i do.Injector, gcfg *domain.GlobalConfig) *mediaauth.Authorizer { +// Authorization is policy-based: each stream binds to at most one named Policy +// (Stream.PlaybackPolicy); a stream with no policy is public. The country +// backend reuses the sessions GeoIP resolver; the per-stream policy CODE is +// resolved from the publisher's in-memory table (O(1)) with a store+template +// fallback for stopped (DVR) streams. Policies are loaded from the store and +// hot-reloaded by the policy handler on CRUD. +func wireMediaAuth(i do.Injector, _ *domain.GlobalConfig) *mediaauth.Authorizer { pub := do.MustInvoke[*publisher.Service](i) apiSrv := do.MustInvoke[*api.Server](i) geoIP := do.MustInvoke[*sessions.SwappableGeoIP](i) streamRepo := do.MustInvoke[store.StreamRepository](i) templateRepo := do.MustInvoke[store.TemplateRepository](i) + policyRepo := do.MustInvoke[store.PolicyRepository](i) - // policyFor resolves a stream's effective playback policy. Live streams hit - // the publisher's in-memory table (O(1), no store read on the hot path). A + // resolvePolicy returns the policy CODE a stream is bound to. Live streams + // hit the publisher's in-memory table (no store read on the hot path). A // STOPPED stream — whose DVR archive is still served — falls back to the - // store + template so its per-stream `token` policy isn't silently - // downgraded to the global default. - policyFor := func(code domain.StreamCode) string { - if policy, running := pub.PlaybackPolicy(code); running { - return policy + // store + template so its policy binding survives across restarts. + resolvePolicy := func(code domain.StreamCode) domain.PolicyCode { + if pc, running := pub.PlaybackPolicy(code); running { + return pc } s, err := streamRepo.FindByCode(context.Background(), code) if err != nil { @@ -366,16 +373,21 @@ func wireMediaAuth(i do.Injector, gcfg *domain.GlobalConfig) *mediaauth.Authoriz s = domain.ResolveStream(s, tpl) } } - return s.PlaybackAuth + return s.PlaybackPolicy } - cfg := config.AuthConfig{} - if gcfg.Auth != nil { - cfg = *gcfg.Auth + authz := mediaauth.New(geoIP.Country, resolvePolicy) + // Load the persisted policy set so rules are live from boot. + if ps, err := policyRepo.List(context.Background()); err != nil { + slog.Warn("media-auth: failed to load policies at startup; all playback is public until reload", "err", err) + } else { + authz.SetPolicies(ps) } - authz := mediaauth.New(cfg.Media, geoIP.Country, policyFor) + pub.SetMediaAuthorizer(authz) apiSrv.SetMediaAuthorizer(authz) + // Let the policy handler hot-reload the compiled set on CRUD. + do.MustInvoke[*handler.PolicyHandler](i).SetAuthorizer(authz) return authz } diff --git a/config/config.go b/config/config.go index 82b33fd8..3ac85e6f 100644 --- a/config/config.go +++ b/config/config.go @@ -246,47 +246,11 @@ type HooksConfig struct { BatchMaxQueueItems int `mapstructure:"batch_max_queue_items" json:"batch_max_queue_items,omitempty" yaml:"batch_max_queue_items,omitempty"` } -// AuthConfig holds authentication settings: the control-plane HTTP API (admin) -// and the media/playback plane (who may watch a stream). +// AuthConfig holds authentication settings for the control-plane HTTP API +// (admin). Media/playback-plane authorization is policy-based and lives in the +// store as domain.Policy entities (managed via /policies), not in this config. type AuthConfig struct { - API APIAuthConfig `mapstructure:"api" json:"api" yaml:"api"` - Media MediaAuthConfig `mapstructure:"media" json:"media" yaml:"media"` -} - -// MediaAuthConfig configures playback (media-plane) authorization: who may -// watch a stream over HLS/DASH/HTTP-MPEGTS/RTMP/SRT/RTSP. Disabled by default -// (Enabled=false → every request is allowed, preserving prior behaviour). -// -// Evaluation per request (a chain; deny wins, allow-lists restrict, then the -// policy gate): a value on any Deny* list → reject; for any non-empty Allow* -// list the request's value MUST appear on it or it is rejected; finally if the -// effective policy (per-stream override, else DefaultPolicy) is "token" a valid -// signed token is required. AllowedDomains gates the HTTP Referer for embeds. -// Static rules are global; per-stream config overrides only the policy today. -type MediaAuthConfig struct { - Enabled bool `mapstructure:"enabled" json:"enabled" yaml:"enabled"` - - // DefaultPolicy is "public" (no token needed) or "token" (signed token - // required) for streams that don't set their own. Empty = public. - DefaultPolicy string `mapstructure:"default_policy" json:"default_policy,omitempty" yaml:"default_policy,omitempty"` - - // TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback - // tokens. Clients (your app) mint tokens with the same secret — the server - // never issues them. Required when any stream's effective policy is "token". - // See internal/mediaauth.SignToken for the canonical token format. - TokenSecret string `mapstructure:"token_secret" json:"token_secret,omitempty" yaml:"token_secret,omitempty"` - - // Static allow/deny chain. IPs accept exact addresses or CIDR ranges; - // Countries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see - // SessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring; - // AllowedDomains match the Referer host (exact or parent domain). - AllowIPs []string `mapstructure:"allow_ips" json:"allow_ips,omitempty" yaml:"allow_ips,omitempty"` - DenyIPs []string `mapstructure:"deny_ips" json:"deny_ips,omitempty" yaml:"deny_ips,omitempty"` - AllowCountries []string `mapstructure:"allow_countries" json:"allow_countries,omitempty" yaml:"allow_countries,omitempty"` - DenyCountries []string `mapstructure:"deny_countries" json:"deny_countries,omitempty" yaml:"deny_countries,omitempty"` - AllowUserAgents []string `mapstructure:"allow_user_agents" json:"allow_user_agents,omitempty" yaml:"allow_user_agents,omitempty"` - DenyUserAgents []string `mapstructure:"deny_user_agents" json:"deny_user_agents,omitempty" yaml:"deny_user_agents,omitempty"` - AllowedDomains []string `mapstructure:"allowed_domains" json:"allowed_domains,omitempty" yaml:"allowed_domains,omitempty"` + API APIAuthConfig `mapstructure:"api" json:"api" yaml:"api"` } // APIAuthConfig configures HTTP Basic auth on the management API. When diff --git a/internal/api/handler/policy.go b/internal/api/handler/policy.go new file mode 100644 index 00000000..2aa71fc4 --- /dev/null +++ b/internal/api/handler/policy.go @@ -0,0 +1,235 @@ +package handler + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/ntt0601zcoder/open-streamer/internal/domain" + "github.com/ntt0601zcoder/open-streamer/internal/events" + "github.com/ntt0601zcoder/open-streamer/internal/store" + "github.com/samber/do/v2" +) + +// policyAuthorizer is the subset of *mediaauth.Authorizer the handler needs to +// hot-swap the compiled policy set after a CRUD change. Defined here (not +// imported) so the handler stays decoupled from the mediaauth package and is +// trivially mockable in tests. +type policyAuthorizer interface { + SetPolicies([]*domain.Policy) +} + +// PolicyHandler handles media-auth Policy CRUD endpoints. After every Save / +// Delete it reloads the authorizer's compiled rule set so changes take effect +// immediately, without restarting any stream. Delete refuses (409) while any +// stream or template still references the policy. +type PolicyHandler struct { + policyRepo store.PolicyRepository + streamRepo store.StreamRepository + templateRepo store.TemplateRepository + authz policyAuthorizer + bus events.Bus +} + +// NewPolicyHandler creates a PolicyHandler and registers it with DI. The +// authorizer is wired separately via SetAuthorizer because it is built after +// the publisher in the post-DI wiring step (see cmd/server.wireMediaAuth). +func NewPolicyHandler(i do.Injector) (*PolicyHandler, error) { + return &PolicyHandler{ + policyRepo: do.MustInvoke[store.PolicyRepository](i), + streamRepo: do.MustInvoke[store.StreamRepository](i), + templateRepo: do.MustInvoke[store.TemplateRepository](i), + bus: do.MustInvoke[events.Bus](i), + }, nil +} + +// SetAuthorizer wires the media-auth authorizer so CRUD changes hot-reload its +// compiled policy set. Nil-safe — tests may leave it unset. +func (h *PolicyHandler) SetAuthorizer(a policyAuthorizer) { h.authz = a } + +// reloadAuthorizer rebuilds the authorizer's compiled policy set from the store. +// Nil-safe; failures are logged at WARN — the on-disk state is already the +// source of truth and the next change retries the swap. +func (h *PolicyHandler) reloadAuthorizer(ctx context.Context) { + if h.authz == nil { + return + } + ps, err := h.policyRepo.List(ctx) + if err != nil { + slog.Warn("policy handler: authorizer reload failed", "err", err) + return + } + h.authz.SetPolicies(ps) +} + +func (h *PolicyHandler) publishPolicyEvent(r *http.Request, typ domain.EventType, code domain.PolicyCode) { + if h.bus == nil || code == "" { + return + } + h.bus.Publish(r.Context(), domain.Event{ + Type: typ, + Payload: map[string]any{"policy_code": string(code)}, + }) +} + +// List policies. +// @Summary List media-auth policies +// @Tags policies +// @Produce json +// @Success 200 {object} map[string]any +// @Failure 500 {object} apidocs.ErrorBody +// @Router /policies [get]. +func (h *PolicyHandler) List(w http.ResponseWriter, r *http.Request) { + ps, err := h.policyRepo.List(r.Context()) + if err != nil { + serverError(w, r, "LIST_FAILED", "list policies", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": ps, "total": len(ps)}) +} + +// Get returns one policy by code. +// @Summary Get media-auth policy +// @Tags policies +// @Produce json +// @Param code path string true "Policy code" +// @Success 200 {object} map[string]any +// @Failure 404 {object} apidocs.ErrorBody +// @Router /policies/{code} [get]. +func (h *PolicyHandler) Get(w http.ResponseWriter, r *http.Request) { + code := domain.PolicyCode(chi.URLParam(r, "code")) + if err := domain.ValidatePolicyCode(string(code)); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_POLICY_CODE", err.Error()) + return + } + p, err := h.policyRepo.FindByCode(r.Context(), code) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": p}) +} + +// Put creates or replaces a policy, then hot-reloads the authorizer. The body's +// code field is ignored — the URL param is authoritative — so an operator can't +// rename a policy by editing the body (which would orphan dependent streams). +// @Summary Create or update media-auth policy +// @Tags policies +// @Accept json +// @Produce json +// @Param code path string true "Policy code" +// @Param body body domain.Policy true "Policy configuration" +// @Success 200 {object} map[string]any +// @Success 201 {object} map[string]any +// @Failure 400 {object} apidocs.ErrorBody +// @Failure 500 {object} apidocs.ErrorBody +// @Router /policies/{code} [post]. +func (h *PolicyHandler) Put(w http.ResponseWriter, r *http.Request) { + code := domain.PolicyCode(chi.URLParam(r, "code")) + if err := domain.ValidatePolicyCode(string(code)); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_POLICY_CODE", err.Error()) + return + } + + var body domain.Policy + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) + return + } + body.Code = code // URL param wins so the persisted record can't drift + if err := body.Validate(); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_POLICY", err.Error()) + return + } + + prior, _ := h.policyRepo.FindByCode(r.Context(), code) + created := prior == nil + + if err := h.policyRepo.Save(r.Context(), &body); err != nil { + serverError(w, r, "SAVE_FAILED", "save policy", err) + return + } + h.reloadAuthorizer(r.Context()) + + eventType := domain.EventPolicyUpdated + status := http.StatusOK + if created { + eventType = domain.EventPolicyCreated + status = http.StatusCreated + } + h.publishPolicyEvent(r, eventType, body.Code) + writeJSON(w, status, map[string]any{"data": body}) +} + +// Delete removes a policy. Returns 409 Conflict when any stream or template +// still references it — operators must detach the dependents first. +// @Summary Delete media-auth policy +// @Tags policies +// @Produce json +// @Param code path string true "Policy code" +// @Success 204 "No Content" +// @Failure 404 {object} apidocs.ErrorBody +// @Failure 409 {object} apidocs.ErrorBody +// @Failure 500 {object} apidocs.ErrorBody +// @Router /policies/{code} [delete]. +func (h *PolicyHandler) Delete(w http.ResponseWriter, r *http.Request) { + code := domain.PolicyCode(chi.URLParam(r, "code")) + if err := domain.ValidatePolicyCode(string(code)); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_POLICY_CODE", err.Error()) + return + } + + if _, err := h.policyRepo.FindByCode(r.Context(), code); err != nil { + writeStoreError(w, r, err) + return + } + + streams, templates, err := h.findReferences(r.Context(), code) + if err != nil { + serverError(w, r, "LIST_FAILED", "scan references for policy", err) + return + } + if len(streams) > 0 || len(templates) > 0 { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "POLICY_IN_USE", + "message": "policy is referenced by one or more streams or templates", + "streams": streams, + "templates": templates, + }) + return + } + + if err := h.policyRepo.Delete(r.Context(), code); err != nil { + serverError(w, r, "DELETE_FAILED", "delete policy", err) + return + } + h.reloadAuthorizer(r.Context()) + h.publishPolicyEvent(r, domain.EventPolicyDeleted, code) + w.WriteHeader(http.StatusNoContent) +} + +// findReferences returns the codes of every stream and template that binds to +// the policy. Linear scans are fine for the dataset sizes Open-Streamer targets. +func (h *PolicyHandler) findReferences(ctx context.Context, code domain.PolicyCode) (streams, templates []string, err error) { + ss, err := h.streamRepo.List(ctx, store.StreamFilter{}) + if err != nil { + return nil, nil, err + } + for _, s := range ss { + if s != nil && s.PlaybackPolicy == code { + streams = append(streams, string(s.Code)) + } + } + tpls, err := h.templateRepo.List(ctx) + if err != nil { + return nil, nil, err + } + for _, t := range tpls { + if t != nil && t.PlaybackPolicy == code { + templates = append(templates, string(t.Code)) + } + } + return streams, templates, nil +} diff --git a/internal/api/server.go b/internal/api/server.go index 6c4bbfe3..d2b41405 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -41,6 +41,7 @@ type Server struct { vodH *handler.VODHandler sessionH *handler.SessionHandler watermarkH *handler.WatermarkHandler + policyH *handler.PolicyHandler // sessTracker is the shared play-sessions tracker. Used to wrap the // mediaserve mount with a tracking middleware so HLS / DASH segment @@ -94,6 +95,7 @@ func New(i do.Injector) (*Server, error) { vodH: do.MustInvoke[*handler.VODHandler](i), sessionH: do.MustInvoke[*handler.SessionHandler](i), watermarkH: do.MustInvoke[*handler.WatermarkHandler](i), + policyH: do.MustInvoke[*handler.PolicyHandler](i), } // Tracker is optional: only wrap mediaserve when the sessions feature is // wired in DI. Treats a missing provider as "feature disabled" rather @@ -257,6 +259,16 @@ func (s *Server) buildRouter(serverCfg *config.ServerConfig) *chi.Mux { }) }) + // Media-auth policies. Single-segment codes — same shape as templates. + ar.Route("/policies", func(r chi.Router) { + r.Get("/", s.policyH.List) + r.Route("/{code}", func(r chi.Router) { + r.Get("/", s.policyH.Get) + r.Post("/", s.policyH.Put) + r.Delete("/", s.policyH.Delete) + }) + }) + ar.Route("/sessions", func(r chi.Router) { r.Get("/", s.sessionH.List) r.Route("/{id}", func(r chi.Router) { diff --git a/internal/domain/event.go b/internal/domain/event.go index e28c4e69..9db96149 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -63,6 +63,13 @@ const ( EventTemplateUpdated EventType = "template.updated" EventTemplateDeleted EventType = "template.deleted" + // Policies — published by the policy handler on REST CRUD of a media-auth + // Policy. The authorizer's compiled rule set is hot-swapped before the + // event fires, so consumers can assume the new rules are already live. + EventPolicyCreated EventType = "policy.created" + EventPolicyUpdated EventType = "policy.updated" + EventPolicyDeleted EventType = "policy.deleted" + // Runtime streams — published by the autopublish service when a // template-prefix match materialises (or expires) a stream that does // NOT exist in the persisted config. Useful for audit logs and the diff --git a/internal/domain/policy.go b/internal/domain/policy.go new file mode 100644 index 00000000..e69aa4bf --- /dev/null +++ b/internal/domain/policy.go @@ -0,0 +1,136 @@ +package domain + +import ( + "errors" + "fmt" + "net" + "regexp" + "strings" +) + +// MaxPolicyCodeLen is the maximum length of a playback-policy code. +const MaxPolicyCodeLen = 128 + +// policyCodePattern allows alphanumerics, dash, and underscore. Like template +// codes, policies live in a flat namespace — they are referenced by streams +// (Stream.PlaybackPolicy), not mounted on a media URL path. +var policyCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// PolicyCode is the user-assigned primary key for a playback policy. +// Allowed characters: a-z, A-Z, 0-9, underscore, dash. +type PolicyCode string + +// ValidatePolicyCode reports whether s is a non-empty valid policy code. +func ValidatePolicyCode(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("policy code is required") + } + if len(s) > MaxPolicyCodeLen { + return fmt.Errorf("policy code exceeds max length %d", MaxPolicyCodeLen) + } + if !policyCodePattern.MatchString(s) { + return errors.New("policy code must contain only A-Z, a-z, 0-9, '_', and '-'") + } + return nil +} + +// Policy is a reusable, named bundle of media-plane (playback) authorization +// rules. A stream references at most one Policy via Stream.PlaybackPolicy; a +// stream with no policy is public (allow-all). Each policy is fully +// self-contained — it carries its OWN token secret and its OWN static +// allow/deny chain. There is no global media-auth config. +// +// Evaluation per playback request (a chain; deny wins, allow-lists restrict, +// then the token gate): a value on any Deny* list rejects; for any non-empty +// Allow* list the request's value MUST appear on it; finally if RequireToken is +// set a valid signed token (HMAC over the stream code, keyed by TokenSecret) is +// required. AllowedDomains gates the HTTP Referer host for browser embeds. +type Policy struct { + // Code is the unique key chosen by the operator. + Code PolicyCode `json:"code" yaml:"code"` + + // Name and Description are operator-facing metadata. + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // RequireToken makes a valid signed playback token mandatory. The server + // only VERIFIES tokens; clients mint them with TokenSecret — see + // internal/mediaauth.SignToken for the canonical token format. + RequireToken bool `json:"require_token,omitempty" yaml:"require_token,omitempty"` + + // TokenSecret is this policy's HMAC-SHA256 verification key. Required when + // RequireToken is true. Each policy owns its own secret so revoking one + // policy's key never affects another. + TokenSecret string `json:"token_secret,omitempty" yaml:"token_secret,omitempty"` + + // Static allow/deny chain. IPs accept exact addresses or CIDR ranges; + // Countries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see + // SessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring; + // AllowedDomains match the Referer host (exact or parent domain). + AllowIPs []string `json:"allow_ips,omitempty" yaml:"allow_ips,omitempty"` + DenyIPs []string `json:"deny_ips,omitempty" yaml:"deny_ips,omitempty"` + AllowCountries []string `json:"allow_countries,omitempty" yaml:"allow_countries,omitempty"` + DenyCountries []string `json:"deny_countries,omitempty" yaml:"deny_countries,omitempty"` + AllowUserAgents []string `json:"allow_user_agents,omitempty" yaml:"allow_user_agents,omitempty"` + DenyUserAgents []string `json:"deny_user_agents,omitempty" yaml:"deny_user_agents,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty" yaml:"allowed_domains,omitempty"` +} + +// countryCodePattern matches a two-letter ISO 3166-1 alpha-2 code (any case). +var countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2}$`) + +// Validate reports whether the policy is internally consistent: a valid code, +// a secret present when a token is required, and every IP / country entry +// well-formed. It is called at save time so bad rules never reach the on-disk +// store (where a silently-dropped CIDR could weaken a deny list). +func (p *Policy) Validate() error { + if err := ValidatePolicyCode(string(p.Code)); err != nil { + return err + } + if p.RequireToken && strings.TrimSpace(p.TokenSecret) == "" { + return errors.New("policy requires a token but token_secret is empty") + } + if err := validateIPRules("allow_ips", p.AllowIPs); err != nil { + return err + } + if err := validateIPRules("deny_ips", p.DenyIPs); err != nil { + return err + } + if err := validateCountryCodes("allow_countries", p.AllowCountries); err != nil { + return err + } + if err := validateCountryCodes("deny_countries", p.DenyCountries); err != nil { + return err + } + return nil +} + +// validateIPRules checks each entry parses as a bare IP or a CIDR range. +func validateIPRules(field string, rules []string) error { + for _, raw := range rules { + s := strings.TrimSpace(raw) + if s == "" { + return fmt.Errorf("%s: empty entry", field) + } + if _, _, err := net.ParseCIDR(s); err == nil { + continue + } + if net.ParseIP(s) != nil { + continue + } + return fmt.Errorf("%s: %q is not a valid IP address or CIDR range", field, s) + } + return nil +} + +// validateCountryCodes checks each entry is a two-letter ISO 3166-1 code. +func validateCountryCodes(field string, codes []string) error { + for _, raw := range codes { + s := strings.TrimSpace(raw) + if !countryCodePattern.MatchString(s) { + return fmt.Errorf("%s: %q is not a 2-letter ISO 3166-1 country code", field, raw) + } + } + return nil +} diff --git a/internal/domain/policy_test.go b/internal/domain/policy_test.go new file mode 100644 index 00000000..c6e2d7f6 --- /dev/null +++ b/internal/domain/policy_test.go @@ -0,0 +1,65 @@ +package domain + +import "testing" + +func TestValidatePolicyCode(t *testing.T) { + t.Parallel() + good := []string{"vip", "geo_block", "token-only", "P1"} + for _, c := range good { + if err := ValidatePolicyCode(c); err != nil { + t.Errorf("ValidatePolicyCode(%q) = %v, want nil", c, err) + } + } + bad := []string{"", " ", "with/slash", "has space", "dot.dot"} + for _, c := range bad { + if err := ValidatePolicyCode(c); err == nil { + t.Errorf("ValidatePolicyCode(%q) = nil, want error", c) + } + } +} + +func TestPolicyValidate(t *testing.T) { + t.Parallel() + cases := []struct { + name string + p Policy + wantErr bool + }{ + {"minimal", Policy{Code: "p1"}, false}, + {"full ok", *NewFullPolicyForTest("p1"), false}, + {"bad code", Policy{Code: "a/b"}, true}, + {"token no secret", Policy{Code: "p1", RequireToken: true}, true}, + {"token with secret", Policy{Code: "p1", RequireToken: true, TokenSecret: "sk"}, false}, + {"bad allow ip", Policy{Code: "p1", AllowIPs: []string{"not-an-ip"}}, true}, + {"bad deny cidr", Policy{Code: "p1", DenyIPs: []string{"10.0.0.0/99"}}, true}, + {"good cidr + ip", Policy{Code: "p1", AllowIPs: []string{"10.0.0.0/8", "1.2.3.4"}}, false}, + {"bad country", Policy{Code: "p1", AllowCountries: []string{"VNM"}}, true}, + {"good country", Policy{Code: "p1", DenyCountries: []string{"vn", "RU"}}, false}, + {"empty ip entry", Policy{Code: "p1", AllowIPs: []string{""}}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := c.p.Validate() + if (err != nil) != c.wantErr { + t.Errorf("Validate() err = %v, wantErr = %v", err, c.wantErr) + } + }) + } +} + +// NewFullPolicyForTest mirrors storetest.NewFullPolicy but lives in-package so +// the domain test avoids importing storetest (which imports domain). +func NewFullPolicyForTest(code PolicyCode) *Policy { + return &Policy{ + Code: code, + Name: "Full", + RequireToken: true, + TokenSecret: "sk", + AllowIPs: []string{"203.0.113.10", "10.0.0.0/8"}, + DenyIPs: []string{"198.51.100.7"}, + AllowCountries: []string{"VN", "SG"}, + DenyCountries: []string{"CN"}, + AllowUserAgents: []string{"exoplayer"}, + AllowedDomains: []string{"example.com"}, + } +} diff --git a/internal/domain/stream.go b/internal/domain/stream.go index d706f183..467e4092 100644 --- a/internal/domain/stream.go +++ b/internal/domain/stream.go @@ -70,11 +70,12 @@ type Stream struct { // StreamKey is used to authenticate RTMP/SRT push ingest. StreamKey string `json:"stream_key" yaml:"stream_key"` - // PlaybackAuth overrides the global media-auth default policy for this - // stream: "public" (no token) or "token" (signed token required). Empty - // inherits auth.media.default_policy. Static rules (IP/country/UA/domains) - // remain global. See internal/mediaauth. - PlaybackAuth string `json:"playback_auth,omitempty" yaml:"playback_auth,omitempty"` + // PlaybackPolicy binds this stream to a named media-auth Policy by its + // code (see domain.Policy). Empty means no policy — the stream is public + // (allow-all). The referenced policy carries the full rule set (token + // requirement + secret, IP/country/UA/domain allow-deny lists). See + // internal/mediaauth. + PlaybackPolicy PolicyCode `json:"playback_policy,omitempty" yaml:"playback_policy,omitempty"` // Status is the runtime lifecycle state. // It is never persisted — always computed from the coordinator's in-memory diff --git a/internal/domain/template.go b/internal/domain/template.go index b852102a..0393ca42 100644 --- a/internal/domain/template.go +++ b/internal/domain/template.go @@ -69,9 +69,10 @@ type Template struct { // stream may set its own (or none). StreamKey string `json:"stream_key,omitempty" yaml:"stream_key,omitempty"` - // PlaybackAuth is the media-auth policy ("public"/"token") inherited by - // streams referencing this template. Empty = inherit global default. - PlaybackAuth string `json:"playback_auth,omitempty" yaml:"playback_auth,omitempty"` + // PlaybackPolicy is the media-auth Policy code inherited by streams + // referencing this template (see domain.Policy). Empty = no policy + // (inheriting streams stay public unless they set their own). + PlaybackPolicy PolicyCode `json:"playback_policy,omitempty" yaml:"playback_policy,omitempty"` // Prefixes is the list of URL-path prefixes that trigger auto-publish. // When an encoder pushes to a path whose first segment(s) match any @@ -240,8 +241,8 @@ func ResolveStream(s *Stream, tpl *Template) *Stream { if out.StreamKey == "" && tpl.StreamKey != "" { out.StreamKey = tpl.StreamKey } - if out.PlaybackAuth == "" && tpl.PlaybackAuth != "" { - out.PlaybackAuth = tpl.PlaybackAuth + if out.PlaybackPolicy == "" && tpl.PlaybackPolicy != "" { + out.PlaybackPolicy = tpl.PlaybackPolicy } if len(out.Inputs) == 0 && len(tpl.Inputs) > 0 { out.Inputs = tpl.Inputs diff --git a/internal/mediaauth/mediaauth.go b/internal/mediaauth/mediaauth.go index 642fb3d4..5bd27184 100644 --- a/internal/mediaauth/mediaauth.go +++ b/internal/mediaauth/mediaauth.go @@ -3,20 +3,26 @@ // counterpart to the control-plane (admin) auth in internal/api: that gates // who can CONFIGURE the server; this gates who can WATCH a stream. // +// Authorization is policy-based. A stream references at most one named Policy +// (domain.Policy) via Stream.PlaybackPolicy; a stream with NO policy is public +// (allow-all). Each policy is fully self-contained — it carries its OWN token +// secret and its OWN static allow/deny chain. There is no global media-auth +// config. +// // One Authorizer is shared by all protocol handlers. Each handler builds an -// AuthRequest from its connection and calls Authorize before allocating any -// streaming state. Evaluation is a Flussonic-style chain — deny wins, allow -// lists restrict, then a per-stream token-policy gate: +// AuthRequest and calls Authorize before allocating any streaming state. The +// chain (deny wins, allow-lists restrict, then a token gate) runs against the +// stream's resolved policy: // -// 1. ClientIP / Country / User-Agent on a Deny* list → DENY -// 2. any non-empty Allow* list the request value misses → DENY -// 3. AllowedDomains set and the Referer host isn't covered → DENY -// 4. effective policy == "token" and the signed token is missing/invalid → DENY -// 5. otherwise → ALLOW +// 1. stream has no policy → ALLOW +// 2. stream references an unknown policy → DENY (fail-closed) +// 3. ClientIP / Country / User-Agent on a Deny* list → DENY +// 4. any non-empty Allow* list the request value misses → DENY +// 5. AllowedDomains set and the Referer host isn't covered → DENY +// 6. policy requires a token and it is missing/invalid → DENY +// 7. otherwise → ALLOW // -// Disabled (Enabled=false, the default) short-circuits to ALLOW so existing -// deployments are unaffected until an operator turns it on. Config is swapped -// atomically for hot-reload. +// The compiled policy set is swapped atomically for hot-reload (SetPolicies). package mediaauth import ( @@ -31,18 +37,11 @@ import ( "sync/atomic" "time" - "github.com/ntt0601zcoder/open-streamer/config" "github.com/ntt0601zcoder/open-streamer/internal/domain" ) -// Policy values for a stream's effective playback policy. -const ( - PolicyPublic = "public" // no token required - PolicyToken = "token" // a valid signed token is required - - // clockSkew tolerates small clock differences when checking token expiry. - clockSkew = 60 * time.Second -) +// clockSkew tolerates small clock differences when checking token expiry. +const clockSkew = 60 * time.Second // AuthRequest is the per-request context a protocol handler hands to Authorize. type AuthRequest struct { @@ -67,23 +66,23 @@ func deny(reason string) Decision { return Decision{Allow: false, Reason: reason // unknown / unavailable. Backed by the sessions GeoIP database. type GeoResolver func(net.IP) string -// PolicyResolver returns a stream's own playback policy ("public"/"token") or -// "" to inherit the global default. Backed by the publisher's in-memory stream -// table so it costs an O(1) map lookup, not a store read per segment. -type PolicyResolver func(domain.StreamCode) string +// PolicyResolver returns the code of the Policy a stream is bound to, or "" when +// the stream has no policy (→ public). Backed by the publisher's in-memory +// stream table for live streams (O(1), no store read per segment) with a +// store+template fallback for stopped streams whose DVR archive is still served. +type PolicyResolver func(domain.StreamCode) domain.PolicyCode -// Authorizer evaluates the chain. Safe for concurrent use. +// Authorizer evaluates the policy chain. Safe for concurrent use. type Authorizer struct { - state atomic.Pointer[state] - geo GeoResolver - policy PolicyResolver + policies atomic.Pointer[map[domain.PolicyCode]*policy] + geo GeoResolver + resolve PolicyResolver } -// state is an immutable, pre-parsed snapshot of MediaAuthConfig. -type state struct { - enabled bool - defaultPolicy string - secret []byte +// policy is an immutable, pre-parsed snapshot of one domain.Policy. +type policy struct { + requireToken bool + secret []byte allowNets, denyNets []*net.IPNet allowIPs, denyIPs map[string]struct{} // exact IPs (canonical string) @@ -97,34 +96,40 @@ type state struct { hasDomainAllow bool } -// New builds an Authorizer. geo and policy may be nil (country rules then never -// match an allow-list, and every stream uses the global default policy). -func New(cfg config.MediaAuthConfig, geo GeoResolver, policy PolicyResolver) *Authorizer { - a := &Authorizer{geo: geo, policy: policy} - a.SetConfig(cfg) +// New builds an Authorizer with an empty policy set. geo and resolve may be nil +// (country rules then never match an allow-list, and every stream resolves to no +// policy → allow-all). Call SetPolicies to load the compiled rule set. +func New(geo GeoResolver, resolve PolicyResolver) *Authorizer { + a := &Authorizer{geo: geo, resolve: resolve} + a.SetPolicies(nil) return a } -// SetConfig swaps in a freshly-parsed snapshot (config hot-reload). -func (a *Authorizer) SetConfig(cfg config.MediaAuthConfig) { - a.state.Store(buildState(cfg)) +// SetPolicies compiles and atomically swaps in a fresh policy set (hot-reload). +// Safe to call concurrently with Authorize. +func (a *Authorizer) SetPolicies(ps []*domain.Policy) { + m := make(map[domain.PolicyCode]*policy, len(ps)) + for _, p := range ps { + if p == nil { + continue + } + m[p.Code] = compilePolicy(p) + } + a.policies.Store(&m) } -func buildState(cfg config.MediaAuthConfig) *state { - st := &state{ - enabled: cfg.Enabled, - defaultPolicy: strings.ToLower(strings.TrimSpace(cfg.DefaultPolicy)), - secret: []byte(cfg.TokenSecret), - allowIPs: map[string]struct{}{}, - denyIPs: map[string]struct{}{}, - allowCountries: upperSet(cfg.AllowCountries), - denyCountries: upperSet(cfg.DenyCountries), - allowUAs: lowerList(cfg.AllowUserAgents), - denyUAs: lowerList(cfg.DenyUserAgents), - allowedDomains: lowerList(cfg.AllowedDomains), - } - st.allowNets, st.allowIPs = parseIPRules(cfg.AllowIPs) - st.denyNets, st.denyIPs = parseIPRules(cfg.DenyIPs) +func compilePolicy(p *domain.Policy) *policy { + st := &policy{ + requireToken: p.RequireToken, + secret: []byte(p.TokenSecret), + allowCountries: upperSet(p.AllowCountries), + denyCountries: upperSet(p.DenyCountries), + allowUAs: lowerList(p.AllowUserAgents), + denyUAs: lowerList(p.DenyUserAgents), + allowedDomains: lowerList(p.AllowedDomains), + } + st.allowNets, st.allowIPs = parseIPRules(p.AllowIPs) + st.denyNets, st.denyIPs = parseIPRules(p.DenyIPs) st.hasIPAllow = len(st.allowNets) > 0 || len(st.allowIPs) > 0 st.hasCountryAllow = len(st.allowCountries) > 0 st.hasUAAllow = len(st.allowUAs) > 0 @@ -132,75 +137,99 @@ func buildState(cfg config.MediaAuthConfig) *state { return st } -// Authorize runs the chain and returns the decision. +// Authorize resolves the stream's policy and runs its chain. A stream with no +// policy is public; a reference to an unknown policy fails closed. func (a *Authorizer) Authorize(req AuthRequest) Decision { - st := a.state.Load() - if st == nil || !st.enabled { - return allow() + var code domain.PolicyCode + if a.resolve != nil { + code = a.resolve(req.Code) } - - // 1. Deny lists (hard block, evaluated first). - if ipInRules(req.ClientIP, st.denyNets, st.denyIPs) { - return deny("ip on deny list") + if code == "" { + return allow() // no policy → public + } + m := a.policies.Load() + var pol *policy + if m != nil { + pol = (*m)[code] + } + if pol == nil { + // Stream references a policy that no longer exists — fail closed. + // The policy delete handler refuses to remove a referenced policy, so + // this only happens after a direct store edit; denying is the safe call. + return deny("unknown policy") } - // Resolve country only when a rule needs it. + return pol.evaluate(req, a.geo) +} + +// evaluate runs the deny → allow → token chain for one policy. +func (p *policy) evaluate(req AuthRequest, geo GeoResolver) Decision { + // Country is resolved at most once, only when a rule needs it. country := "" - if (len(st.denyCountries) > 0 || st.hasCountryAllow) && a.geo != nil { - country = strings.ToUpper(a.geo(req.ClientIP)) + if (len(p.denyCountries) > 0 || p.hasCountryAllow) && geo != nil { + country = strings.ToUpper(geo(req.ClientIP)) + } + if d := p.checkDeny(req, country); !d.Allow { + return d + } + if d := p.checkAllow(req, country); !d.Allow { + return d + } + return p.checkToken(req) +} + +// checkDeny applies the hard-block deny lists (evaluated first). +func (p *policy) checkDeny(req AuthRequest, country string) Decision { + if ipInRules(req.ClientIP, p.denyNets, p.denyIPs) { + return deny("ip on deny list") } if country != "" { - if _, bad := st.denyCountries[country]; bad { + if _, bad := p.denyCountries[country]; bad { return deny("country on deny list") } } - if uaMatches(req.UserAgent, st.denyUAs) { + if uaMatches(req.UserAgent, p.denyUAs) { return deny("user-agent on deny list") } + return allow() +} - // 2. Allow gates: each configured list must be satisfied. - if st.hasIPAllow && !ipInRules(req.ClientIP, st.allowNets, st.allowIPs) { +// checkAllow applies the allow gates: each configured list must be satisfied. +func (p *policy) checkAllow(req AuthRequest, country string) Decision { + if p.hasIPAllow && !ipInRules(req.ClientIP, p.allowNets, p.allowIPs) { return deny("ip not on allow list") } - if st.hasCountryAllow { - if _, ok := st.allowCountries[country]; !ok || country == "" { + if p.hasCountryAllow { + if _, ok := p.allowCountries[country]; !ok || country == "" { return deny("country not on allow list") } } - if st.hasUAAllow && !uaMatches(req.UserAgent, st.allowUAs) { + if p.hasUAAllow && !uaMatches(req.UserAgent, p.allowUAs) { return deny("user-agent not on allow list") } - if st.hasDomainAllow && !domainAllowed(req.Referer, st.allowedDomains) { + if p.hasDomainAllow && !domainAllowed(req.Referer, p.allowedDomains) { return deny("referer domain not allowed") } + return allow() +} - // 3. Policy gate: per-stream override, else global default. - policy := st.defaultPolicy - if a.policy != nil { - if p := strings.ToLower(strings.TrimSpace(a.policy(req.Code))); p != "" { - policy = p - } +// checkToken applies the token gate when the policy requires a signed token. +func (p *policy) checkToken(req AuthRequest) Decision { + if !p.requireToken { + return allow() } - if policy == PolicyToken { - if len(st.secret) == 0 { - return deny("token policy but no secret configured") - } - if !st.verify(req.Code, req.Token) { - return deny("missing or invalid token") - } + if len(p.secret) == 0 { + return deny("token required but policy has no secret") + } + if !verify(p.secret, req.Code, req.Token) { + return deny("missing or invalid token") } return allow() } -// Enabled reports whether media auth is on (for handlers / status). -func (a *Authorizer) Enabled() bool { - st := a.state.Load() - return st != nil && st.enabled -} - // ── playback token ── // SignToken is the Go reference implementation of the playback-token format. -// The server only VERIFIES tokens; clients mint them with the shared secret — +// The server only VERIFIES tokens; clients mint them with the policy's secret — // reproduce this in any language: // // exp = future expiry, unix seconds (decimal string) @@ -212,7 +241,7 @@ func SignToken(secret []byte, code domain.StreamCode, exp int64) string { return strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(tokenMAC(secret, code, exp)) } -func (st *state) verify(code domain.StreamCode, token string) bool { +func verify(secret []byte, code domain.StreamCode, token string) bool { dot := strings.IndexByte(token, '.') if dot <= 0 { return false @@ -228,7 +257,7 @@ func (st *state) verify(code domain.StreamCode, token string) bool { if err != nil { return false } - want := tokenMAC(st.secret, code, exp) + want := tokenMAC(secret, code, exp) return subtle.ConstantTimeCompare(got, want) == 1 } diff --git a/internal/mediaauth/mediaauth_test.go b/internal/mediaauth/mediaauth_test.go index e3c734d4..fecce470 100644 --- a/internal/mediaauth/mediaauth_test.go +++ b/internal/mediaauth/mediaauth_test.go @@ -5,56 +5,66 @@ import ( "testing" "time" - "github.com/ntt0601zcoder/open-streamer/config" "github.com/ntt0601zcoder/open-streamer/internal/domain" ) -const testCode = domain.StreamCode("ch1") +const ( + testCode = domain.StreamCode("ch1") + testPolicy = domain.PolicyCode("p1") +) func ip(s string) net.IP { return net.ParseIP(s) } +// authzAll binds EVERY stream code to policy p and loads p as the only policy. +// Use for single-policy chain tests where the request's stream code is +// irrelevant beyond selecting the policy. +func authzAll(geo GeoResolver, p *domain.Policy) *Authorizer { + a := New(geo, func(domain.StreamCode) domain.PolicyCode { return p.Code }) + a.SetPolicies([]*domain.Policy{p}) + return a +} + // ── token sign/verify ── func TestToken_SignVerifyRoundTrip(t *testing.T) { t.Parallel() - st := buildState(config.MediaAuthConfig{Enabled: true, TokenSecret: "sekret"}) + secret := []byte("sekret") exp := time.Now().Add(time.Hour).Unix() - tok := SignToken(st.secret, testCode, exp) - if !st.verify(testCode, tok) { + tok := SignToken(secret, testCode, exp) + if !verify(secret, testCode, tok) { t.Fatal("valid token failed to verify") } } func TestToken_Rejects(t *testing.T) { t.Parallel() - st := buildState(config.MediaAuthConfig{Enabled: true, TokenSecret: "sekret"}) - valid := SignToken(st.secret, testCode, time.Now().Add(time.Hour).Unix()) + secret := []byte("sekret") + valid := SignToken(secret, testCode, time.Now().Add(time.Hour).Unix()) t.Run("expired", func(t *testing.T) { - old := SignToken(st.secret, testCode, time.Now().Add(-2*time.Hour).Unix()) - if st.verify(testCode, old) { + old := SignToken(secret, testCode, time.Now().Add(-2*time.Hour).Unix()) + if verify(secret, testCode, old) { t.Error("expired token verified") } }) t.Run("wrong_stream_code", func(t *testing.T) { - if st.verify("other", valid) { + if verify(secret, "other", valid) { t.Error("token for ch1 verified against 'other'") } }) t.Run("tampered_sig", func(t *testing.T) { - if st.verify(testCode, valid+"x") { + if verify(secret, testCode, valid+"x") { t.Error("tampered token verified") } }) t.Run("different_secret", func(t *testing.T) { - other := buildState(config.MediaAuthConfig{Enabled: true, TokenSecret: "other"}) - if other.verify(testCode, valid) { + if verify([]byte("other"), testCode, valid) { t.Error("token verified under a different secret") } }) t.Run("malformed", func(t *testing.T) { for _, bad := range []string{"", "noexp", "abc.def", ".sig", "999"} { - if st.verify(testCode, bad) { + if verify(secret, testCode, bad) { t.Errorf("malformed token %q verified", bad) } } @@ -63,26 +73,37 @@ func TestToken_Rejects(t *testing.T) { // ── chain ── -func newAuthz(t *testing.T, cfg config.MediaAuthConfig, geo GeoResolver, pol PolicyResolver) *Authorizer { - t.Helper() - return New(cfg, geo, pol) +func TestAuthorize_NoPolicyAllowsAll(t *testing.T) { + t.Parallel() + // nil resolver → no policy for any stream → public. + a := New(nil, nil) + if d := a.Authorize(AuthRequest{Code: testCode, ClientIP: ip("8.8.8.8")}); !d.Allow { + t.Fatalf("no resolver must allow, got deny: %s", d.Reason) + } + // resolver returning "" → no policy → public. + b := New(nil, func(domain.StreamCode) domain.PolicyCode { return "" }) + if d := b.Authorize(AuthRequest{Code: testCode}); !d.Allow { + t.Fatalf("empty policy code must allow, got deny: %s", d.Reason) + } } -func TestAuthorize_DisabledAllowsAll(t *testing.T) { +func TestAuthorize_UnknownPolicyFailsClosed(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{Enabled: false, DefaultPolicy: PolicyToken}, nil, nil) - if d := a.Authorize(AuthRequest{Code: testCode}); !d.Allow { - t.Fatalf("disabled must allow, got deny: %s", d.Reason) + // Stream references a policy that is not in the loaded set → deny. + a := New(nil, func(domain.StreamCode) domain.PolicyCode { return "ghost" }) + a.SetPolicies([]*domain.Policy{{Code: "real"}}) + if d := a.Authorize(AuthRequest{Code: testCode}); d.Allow { + t.Error("reference to an unknown policy must fail closed") } } func TestAuthorize_IPRules(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{ - Enabled: true, + a := authzAll(nil, &domain.Policy{ + Code: testPolicy, DenyIPs: []string{"8.8.8.8", "10.0.0.0/8"}, AllowIPs: []string{"1.2.3.4", "192.168.0.0/16"}, - }, nil, nil) + }) cases := []struct { ip string @@ -112,11 +133,11 @@ func TestAuthorize_CountryRules(t *testing.T) { } return "" // unknown } - a := newAuthz(t, config.MediaAuthConfig{ - Enabled: true, + a := authzAll(geo, &domain.Policy{ + Code: testPolicy, AllowCountries: []string{"VN", "US"}, DenyCountries: []string{"RU"}, - }, geo, nil) + }) if d := a.Authorize(AuthRequest{Code: testCode, ClientIP: ip("1.1.1.1")}); !d.Allow { t.Errorf("VN must be allowed: %s", d.Reason) @@ -131,10 +152,7 @@ func TestAuthorize_CountryRules(t *testing.T) { func TestAuthorize_UserAgentRules(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{ - Enabled: true, - DenyUserAgents: []string{"badbot"}, - }, nil, nil) + a := authzAll(nil, &domain.Policy{Code: testPolicy, DenyUserAgents: []string{"badbot"}}) if d := a.Authorize(AuthRequest{Code: testCode, UserAgent: "Mozilla BadBot/1.0"}); d.Allow { t.Error("denied UA substring must block") } @@ -142,7 +160,7 @@ func TestAuthorize_UserAgentRules(t *testing.T) { t.Errorf("allowed UA must pass: %s", d.Reason) } - b := newAuthz(t, config.MediaAuthConfig{Enabled: true, AllowUserAgents: []string{"exoplayer"}}, nil, nil) + b := authzAll(nil, &domain.Policy{Code: testPolicy, AllowUserAgents: []string{"exoplayer"}}) if d := b.Authorize(AuthRequest{Code: testCode, UserAgent: "ExoPlayerLib/2.1"}); !d.Allow { t.Errorf("UA on allow list must pass: %s", d.Reason) } @@ -153,7 +171,7 @@ func TestAuthorize_UserAgentRules(t *testing.T) { func TestAuthorize_AllowedDomains(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{Enabled: true, AllowedDomains: []string{"example.com"}}, nil, nil) + a := authzAll(nil, &domain.Policy{Code: testPolicy, AllowedDomains: []string{"example.com"}}) cases := []struct { ref string want bool @@ -172,7 +190,7 @@ func TestAuthorize_AllowedDomains(t *testing.T) { func TestAuthorize_TokenPolicy(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{Enabled: true, DefaultPolicy: PolicyToken, TokenSecret: "sk"}, nil, nil) + a := authzAll(nil, &domain.Policy{Code: testPolicy, RequireToken: true, TokenSecret: "sk"}) good := SignToken([]byte("sk"), testCode, time.Now().Add(time.Hour).Unix()) if d := a.Authorize(AuthRequest{Code: testCode, Token: good}); !d.Allow { t.Errorf("valid token must pass: %s", d.Reason) @@ -183,7 +201,8 @@ func TestAuthorize_TokenPolicy(t *testing.T) { if d := a.Authorize(AuthRequest{Code: testCode, Token: "bogus.sig"}); d.Allow { t.Error("bogus token must deny") } - // A token minted for ch1 must not work for another stream. + // authzAll binds ch2 to the same token policy; a token minted for ch1 must + // not authorize ch2 (the MAC is bound to the stream code). if d := a.Authorize(AuthRequest{Code: "ch2", Token: good}); d.Allow { t.Error("token bound to ch1 must not authorize ch2") } @@ -191,51 +210,53 @@ func TestAuthorize_TokenPolicy(t *testing.T) { func TestAuthorize_TokenPolicyNoSecretFailsClosed(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{Enabled: true, DefaultPolicy: PolicyToken}, nil, nil) // no secret + // Defensive: domain.Validate rejects this, but the authorizer must still + // fail closed if a secret-less token policy ever reaches it. + a := authzAll(nil, &domain.Policy{Code: testPolicy, RequireToken: true}) if d := a.Authorize(AuthRequest{Code: testCode, Token: "x.y"}); d.Allow { t.Error("token policy without a secret must fail closed") } } -func TestAuthorize_PerStreamPolicyOverride(t *testing.T) { +func TestAuthorize_PerStreamPolicySelection(t *testing.T) { t.Parallel() - // Global default = token, but ch1 is explicitly public → allowed w/o token. - pol := func(c domain.StreamCode) string { - if c == "public1" { - return PolicyPublic + open := &domain.Policy{Code: "open"} // no token, no lists → public + tok := &domain.Policy{Code: "tok", RequireToken: true, TokenSecret: "sk"} + resolve := func(c domain.StreamCode) domain.PolicyCode { + switch c { + case "public1": + return "open" + case "token1": + return "tok" } - if c == "token1" { - return PolicyToken - } - return "" + return "" // no policy → public } - a := newAuthz(t, config.MediaAuthConfig{Enabled: true, DefaultPolicy: PolicyToken, TokenSecret: "sk"}, nil, pol) + a := New(nil, resolve) + a.SetPolicies([]*domain.Policy{open, tok}) if d := a.Authorize(AuthRequest{Code: "public1"}); !d.Allow { - t.Errorf("per-stream public override must allow without token: %s", d.Reason) + t.Errorf("stream bound to an open policy must allow without token: %s", d.Reason) } - if d := a.Authorize(AuthRequest{Code: "inherits"}); d.Allow { - t.Error("stream inheriting global token policy must require a token") + if d := a.Authorize(AuthRequest{Code: "token1"}); d.Allow { + t.Error("stream bound to a token policy must require a token") } - - // Global default = public, but token1 explicitly requires a token. - b := newAuthz(t, config.MediaAuthConfig{Enabled: true, DefaultPolicy: PolicyPublic, TokenSecret: "sk"}, nil, pol) - if d := b.Authorize(AuthRequest{Code: "token1"}); d.Allow { - t.Error("per-stream token override must require a token even when global is public") + good := SignToken([]byte("sk"), "token1", time.Now().Add(time.Hour).Unix()) + if d := a.Authorize(AuthRequest{Code: "token1", Token: good}); !d.Allow { + t.Errorf("valid token for token1 must allow: %s", d.Reason) } - if d := b.Authorize(AuthRequest{Code: "other"}); !d.Allow { - t.Errorf("public-default stream must allow: %s", d.Reason) + if d := a.Authorize(AuthRequest{Code: "nopolicy"}); !d.Allow { + t.Errorf("stream with no policy must allow: %s", d.Reason) } } func TestAuthorize_DenyBeatsAllow(t *testing.T) { t.Parallel() // IP is on both lists — deny must win. - a := newAuthz(t, config.MediaAuthConfig{ - Enabled: true, + a := authzAll(nil, &domain.Policy{ + Code: testPolicy, AllowIPs: []string{"1.2.3.4"}, DenyIPs: []string{"1.2.3.4"}, - }, nil, nil) + }) if d := a.Authorize(AuthRequest{Code: testCode, ClientIP: ip("1.2.3.4")}); d.Allow { t.Error("deny must take precedence over allow") } @@ -243,11 +264,12 @@ func TestAuthorize_DenyBeatsAllow(t *testing.T) { func TestAuthorize_HotReload(t *testing.T) { t.Parallel() - a := newAuthz(t, config.MediaAuthConfig{Enabled: false}, nil, nil) + a := New(nil, func(domain.StreamCode) domain.PolicyCode { return testPolicy }) + a.SetPolicies([]*domain.Policy{{Code: testPolicy}}) // empty policy → allow all if d := a.Authorize(AuthRequest{Code: testCode, ClientIP: ip("8.8.8.8")}); !d.Allow { - t.Fatal("disabled should allow") + t.Fatalf("empty policy should allow: %s", d.Reason) } - a.SetConfig(config.MediaAuthConfig{Enabled: true, DenyIPs: []string{"8.8.8.8"}}) + a.SetPolicies([]*domain.Policy{{Code: testPolicy, DenyIPs: []string{"8.8.8.8"}}}) if d := a.Authorize(AuthRequest{Code: testCode, ClientIP: ip("8.8.8.8")}); d.Allow { t.Error("after reload, denied IP must be blocked") } diff --git a/internal/publisher/playauth.go b/internal/publisher/playauth.go index 75716e0d..1bf6b924 100644 --- a/internal/publisher/playauth.go +++ b/internal/publisher/playauth.go @@ -16,18 +16,17 @@ import ( // (every playback request is allowed) — the default. func (s *Service) SetMediaAuthorizer(a *mediaauth.Authorizer) { s.mediaAuth = a } -// PlaybackPolicy returns a RUNNING stream's per-stream media-auth policy -// override ("public" / "token" / "" = inherit global) and whether the stream is -// currently running. O(1) in-memory lookup, used as the first tier of the -// authorizer's PolicyResolver so the live hot path never reads the store. The -// `running` flag lets the caller fall back to the store for a STOPPED stream -// (whose DVR archive is still served) instead of mistaking its empty in-memory -// policy for "inherit global". -func (s *Service) PlaybackPolicy(code domain.StreamCode) (policy string, running bool) { +// PlaybackPolicy returns a RUNNING stream's bound media-auth Policy code ("" = +// no policy → public) and whether the stream is currently running. O(1) +// in-memory lookup, used as the first tier of the authorizer's PolicyResolver so +// the live hot path never reads the store. The `running` flag lets the caller +// fall back to the store for a STOPPED stream (whose DVR archive is still +// served) instead of mistaking its empty in-memory binding for "no policy". +func (s *Service) PlaybackPolicy(code domain.StreamCode) (policy domain.PolicyCode, running bool) { s.mu.Lock() defer s.mu.Unlock() if ss, ok := s.streams[code]; ok { - return ss.playbackAuth, true + return ss.playbackPolicy, true } return "", false } diff --git a/internal/publisher/service.go b/internal/publisher/service.go index 9f9687fc..7f4e6f05 100644 --- a/internal/publisher/service.go +++ b/internal/publisher/service.go @@ -82,10 +82,11 @@ type streamState struct { // config changes (Update path). mpegtsEnabled bool - // playbackAuth is the stream's media-auth policy override ("public"/"token"/ - // "" inherit). Mirrored here so PlaybackPolicy answers the authorizer with - // an O(1) in-memory lookup instead of a store read per request. - playbackAuth string + // playbackPolicy is the code of the media-auth Policy this stream binds to + // ("" = no policy → public). Mirrored here so PlaybackPolicy answers the + // authorizer with an O(1) in-memory lookup instead of a store read per + // request. + playbackPolicy domain.PolicyCode } // Service manages all output workers for active streams. @@ -278,13 +279,13 @@ func (s *Service) Start(ctx context.Context, stream *domain.Stream) error { baseCtx, baseCancel := context.WithCancel(ctx) ss := &streamState{ - baseCtx: baseCtx, - baseCancel: baseCancel, - code: stream.Code, - mediaBuf: buffer.PlaybackBufferID(stream.Code, stream.Transcoder), - protocols: make(map[string]context.CancelFunc), - mpegtsEnabled: p.MPEGTS, - playbackAuth: stream.PlaybackAuth, + baseCtx: baseCtx, + baseCancel: baseCancel, + code: stream.Code, + mediaBuf: buffer.PlaybackBufferID(stream.Code, stream.Transcoder), + protocols: make(map[string]context.CancelFunc), + mpegtsEnabled: p.MPEGTS, + playbackPolicy: stream.PlaybackPolicy, } s.streams[stream.Code] = ss s.mediaBuffer[stream.Code] = ss.mediaBuf @@ -452,7 +453,7 @@ func (s *Service) UpdateProtocols(ctx context.Context, old, new *domain.Stream) s.mediaBuffer[new.Code] = newBuf } ss.mpegtsEnabled = np.MPEGTS - ss.playbackAuth = new.PlaybackAuth + ss.playbackPolicy = new.PlaybackPolicy } s.mu.Unlock() if !ok { diff --git a/internal/runtime/manager.go b/internal/runtime/manager.go index 3242db92..74702109 100644 --- a/internal/runtime/manager.go +++ b/internal/runtime/manager.go @@ -20,7 +20,6 @@ import ( "github.com/ntt0601zcoder/open-streamer/internal/hooks" "github.com/ntt0601zcoder/open-streamer/internal/ingestor" "github.com/ntt0601zcoder/open-streamer/internal/manager" - "github.com/ntt0601zcoder/open-streamer/internal/mediaauth" "github.com/ntt0601zcoder/open-streamer/internal/publisher" "github.com/ntt0601zcoder/open-streamer/internal/sessions" "github.com/ntt0601zcoder/open-streamer/internal/store" @@ -46,7 +45,6 @@ type Deps struct { SessionsSvc *sessions.Service AutoPublish *autopublish.Service APISrv *api.Server - MediaAuth *mediaauth.Authorizer Bus events.Bus StreamRepo store.StreamRepository GlobalConfigRepo store.GlobalConfigRepository @@ -244,15 +242,9 @@ func (m *Manager) diff(old, new *domain.GlobalConfig) { m.deps.APISrv.SetAuthConfig(new.Auth) } - // Media-plane auth: hot-swap playback rules (token/IP/country/UA/referer) - // on the shared authorizer used by both the publisher and the API server. - if m.deps.MediaAuth != nil && configChanged(old.Auth, new.Auth) { - media := config.MediaAuthConfig{} - if new.Auth != nil { - media = new.Auth.Media - } - m.deps.MediaAuth.SetConfig(media) - } + // Media-plane (playback) auth is policy-based and store-driven — its + // compiled rule set is hot-reloaded by the policy handler on CRUD, not from + // this config diff. See cmd/server.wireMediaAuth + handler.PolicyHandler. // Push the new listeners snapshot to ingestor + publisher BEFORE // diffService can decide to restart them — each Run() reads the cached diff --git a/internal/store/json/store.go b/internal/store/json/store.go index da448e12..a41ad2ff 100644 --- a/internal/store/json/store.go +++ b/internal/store/json/store.go @@ -34,6 +34,7 @@ const dbFile = "open_streamer.json" type db struct { Streams map[string]*domain.Stream `json:"streams"` Templates map[string]*domain.Template `json:"templates,omitempty"` + Policies map[string]*domain.Policy `json:"policies,omitempty"` Recordings map[string]*domain.Recording `json:"recordings"` Hooks map[string]*domain.Hook `json:"hooks"` VOD map[string]*domain.VODMount `json:"vod,omitempty"` @@ -61,6 +62,9 @@ func (s *Store) Streams() store.StreamRepository { return &streamRepo{s} } // Templates returns a TemplateRepository backed by this Store. func (s *Store) Templates() store.TemplateRepository { return &templateRepo{s} } +// Policies returns a PolicyRepository backed by this Store. +func (s *Store) Policies() store.PolicyRepository { return &policyRepo{s} } + // Recordings returns a RecordingRepository backed by this Store. func (s *Store) Recordings() store.RecordingRepository { return &recordingRepo{s} } @@ -95,6 +99,9 @@ func (s *Store) readDB() (db, error) { if d.Templates == nil { d.Templates = make(map[string]*domain.Template) } + if d.Policies == nil { + d.Policies = make(map[string]*domain.Policy) + } if d.Recordings == nil { d.Recordings = make(map[string]*domain.Recording) } @@ -153,6 +160,7 @@ func emptyDB() db { return db{ Streams: make(map[string]*domain.Stream), Templates: make(map[string]*domain.Template), + Policies: make(map[string]*domain.Policy), Recordings: make(map[string]*domain.Recording), Hooks: make(map[string]*domain.Hook), VOD: make(map[string]*domain.VODMount), @@ -271,6 +279,62 @@ func (r *templateRepo) Delete(_ context.Context, code domain.TemplateCode) error }) } +// --- PolicyRepository --- + +type policyRepo struct{ s *Store } + +// Save implements store.PolicyRepository. +func (r *policyRepo) Save(_ context.Context, policy *domain.Policy) error { + return r.s.modify(func(d *db) error { + d.Policies[string(policy.Code)] = policy + return nil + }) +} + +// FindByCode implements store.PolicyRepository. +func (r *policyRepo) FindByCode(_ context.Context, code domain.PolicyCode) (*domain.Policy, error) { + var result *domain.Policy + err := r.s.readAll(func(d db) error { + p, ok := d.Policies[string(code)] + if !ok { + return fmt.Errorf("policy %s: %w", code, store.ErrNotFound) + } + result = p + return nil + }) + return result, err +} + +// List implements store.PolicyRepository. +func (r *policyRepo) List(_ context.Context) ([]*domain.Policy, error) { + var result []*domain.Policy + err := r.s.readAll(func(d db) error { + result = make([]*domain.Policy, 0, len(d.Policies)) + for _, p := range d.Policies { + result = append(result, p) + } + slices.SortFunc(result, func(a, b *domain.Policy) int { + if a.Code < b.Code { + return -1 + } + if a.Code > b.Code { + return 1 + } + return 0 + }) + return nil + }) + return result, err +} + +// Delete implements store.PolicyRepository. +func (r *policyRepo) Delete(_ context.Context, code domain.PolicyCode) error { + return r.s.modify(func(d *db) error { + delete(d.Policies, string(code)) + return nil + }) +} + // --- RecordingRepository --- type recordingRepo struct{ s *Store } diff --git a/internal/store/json/store_test.go b/internal/store/json/store_test.go index c92712a9..008b1bea 100644 --- a/internal/store/json/store_test.go +++ b/internal/store/json/store_test.go @@ -205,6 +205,58 @@ func TestJSONTemplateRepo_Delete(t *testing.T) { assert.True(t, errors.Is(err, store.ErrNotFound)) } +// --- PolicyRepository --- + +func TestJSONPolicyRepo_SaveAndFindByCode(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + + want := storetest.NewFullPolicy("vip") + require.NoError(t, repo.Save(ctx, want)) + + got, err := repo.FindByCode(ctx, "vip") + require.NoError(t, err) + assert.Equal(t, want.Code, got.Code) + assert.Equal(t, want.RequireToken, got.RequireToken) + assert.Equal(t, want.TokenSecret, got.TokenSecret) + assert.Equal(t, want.AllowIPs, got.AllowIPs) + assert.Equal(t, want.DenyCountries, got.DenyCountries) + assert.Equal(t, want.AllowedDomains, got.AllowedDomains) +} + +func TestJSONPolicyRepo_FindByCode_NotFound(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + _, err := repo.FindByCode(ctx, "nope") + require.Error(t, err) + assert.True(t, errors.Is(err, store.ErrNotFound)) +} + +func TestJSONPolicyRepo_ListSorted(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + + require.NoError(t, repo.Save(ctx, storetest.NewFullPolicy("b_policy"))) + require.NoError(t, repo.Save(ctx, storetest.NewFullPolicy("a_policy"))) + + all, err := repo.List(ctx) + require.NoError(t, err) + require.Len(t, all, 2) + assert.Equal(t, domain.PolicyCode("a_policy"), all[0].Code, "List must be sorted by code asc") + assert.Equal(t, domain.PolicyCode("b_policy"), all[1].Code) +} + +func TestJSONPolicyRepo_Delete(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + + require.NoError(t, repo.Save(ctx, storetest.NewFullPolicy("delete_me"))) + require.NoError(t, repo.Delete(ctx, "delete_me")) + + _, err := repo.FindByCode(ctx, "delete_me") + assert.True(t, errors.Is(err, store.ErrNotFound)) +} + // --- RecordingRepository --- func TestJSONRecordingRepo_SaveAndFindByID(t *testing.T) { diff --git a/internal/store/repository.go b/internal/store/repository.go index af7f368f..a8b253d1 100644 --- a/internal/store/repository.go +++ b/internal/store/repository.go @@ -35,6 +35,15 @@ type TemplateRepository interface { Delete(ctx context.Context, code domain.TemplateCode) error } +// PolicyRepository persists reusable media-auth Policy configurations +// referenced by streams via Stream.PlaybackPolicy. +type PolicyRepository interface { + Save(ctx context.Context, policy *domain.Policy) error + FindByCode(ctx context.Context, code domain.PolicyCode) (*domain.Policy, error) + List(ctx context.Context) ([]*domain.Policy, error) + Delete(ctx context.Context, code domain.PolicyCode) error +} + // RecordingRepository persists DVR recording metadata. type RecordingRepository interface { Save(ctx context.Context, rec *domain.Recording) error diff --git a/internal/store/storetest/fixtures.go b/internal/store/storetest/fixtures.go index 00ffbcbc..6357a81e 100644 --- a/internal/store/storetest/fixtures.go +++ b/internal/store/storetest/fixtures.go @@ -146,6 +146,25 @@ func NewFullTemplate(code domain.TemplateCode) *domain.Template { } } +// NewFullPolicy returns a Policy with every field populated. Useful for +// round-trip tests that verify nothing is silently dropped by serialisation. +func NewFullPolicy(code domain.PolicyCode) *domain.Policy { + return &domain.Policy{ + Code: code, + Name: "Full Policy", + Description: "Full policy fixture", + RequireToken: true, + TokenSecret: "policy-secret-123", + AllowIPs: []string{"203.0.113.10", "10.0.0.0/8"}, + DenyIPs: []string{"198.51.100.7"}, + AllowCountries: []string{"VN", "SG"}, + DenyCountries: []string{"CN"}, + AllowUserAgents: []string{"exoplayer"}, + DenyUserAgents: []string{"badbot"}, + AllowedDomains: []string{"example.com"}, + } +} + // NewFullHook returns a Hook with every field populated. func NewFullHook(id domain.HookID) *domain.Hook { return &domain.Hook{ diff --git a/internal/store/yaml/store.go b/internal/store/yaml/store.go index d48aaf7e..af74c235 100644 --- a/internal/store/yaml/store.go +++ b/internal/store/yaml/store.go @@ -35,6 +35,7 @@ const dbFile = "open_streamer.yaml" type db struct { Streams map[string]*domain.Stream `yaml:"streams,omitempty"` Templates map[string]*domain.Template `yaml:"templates,omitempty"` + Policies map[string]*domain.Policy `yaml:"policies,omitempty"` Recordings map[string]*domain.Recording `yaml:"recordings,omitempty"` Hooks map[string]*domain.Hook `yaml:"hooks,omitempty"` VOD map[string]*domain.VODMount `yaml:"vod,omitempty"` @@ -62,6 +63,9 @@ func (s *Store) Streams() store.StreamRepository { return &streamRepo{s} } // Templates returns a TemplateRepository backed by this Store. func (s *Store) Templates() store.TemplateRepository { return &templateRepo{s} } +// Policies returns a PolicyRepository backed by this Store. +func (s *Store) Policies() store.PolicyRepository { return &policyRepo{s} } + // Recordings returns a RecordingRepository backed by this Store. func (s *Store) Recordings() store.RecordingRepository { return &recordingRepo{s} } @@ -96,6 +100,9 @@ func (s *Store) readDB() (db, error) { if d.Templates == nil { d.Templates = make(map[string]*domain.Template) } + if d.Policies == nil { + d.Policies = make(map[string]*domain.Policy) + } if d.Recordings == nil { d.Recordings = make(map[string]*domain.Recording) } @@ -154,6 +161,7 @@ func emptyDB() db { return db{ Streams: make(map[string]*domain.Stream), Templates: make(map[string]*domain.Template), + Policies: make(map[string]*domain.Policy), Recordings: make(map[string]*domain.Recording), Hooks: make(map[string]*domain.Hook), VOD: make(map[string]*domain.VODMount), @@ -272,6 +280,62 @@ func (r *templateRepo) Delete(_ context.Context, code domain.TemplateCode) error }) } +// --- PolicyRepository --- + +type policyRepo struct{ s *Store } + +// Save implements store.PolicyRepository. +func (r *policyRepo) Save(_ context.Context, policy *domain.Policy) error { + return r.s.modify(func(d *db) error { + d.Policies[string(policy.Code)] = policy + return nil + }) +} + +// FindByCode implements store.PolicyRepository. +func (r *policyRepo) FindByCode(_ context.Context, code domain.PolicyCode) (*domain.Policy, error) { + var result *domain.Policy + err := r.s.readAll(func(d db) error { + p, ok := d.Policies[string(code)] + if !ok { + return fmt.Errorf("policy %s: %w", code, store.ErrNotFound) + } + result = p + return nil + }) + return result, err +} + +// List implements store.PolicyRepository. +func (r *policyRepo) List(_ context.Context) ([]*domain.Policy, error) { + var result []*domain.Policy + err := r.s.readAll(func(d db) error { + result = make([]*domain.Policy, 0, len(d.Policies)) + for _, p := range d.Policies { + result = append(result, p) + } + slices.SortFunc(result, func(a, b *domain.Policy) int { + if a.Code < b.Code { + return -1 + } + if a.Code > b.Code { + return 1 + } + return 0 + }) + return nil + }) + return result, err +} + +// Delete implements store.PolicyRepository. +func (r *policyRepo) Delete(_ context.Context, code domain.PolicyCode) error { + return r.s.modify(func(d *db) error { + delete(d.Policies, string(code)) + return nil + }) +} + // --- RecordingRepository --- type recordingRepo struct{ s *Store } diff --git a/internal/store/yaml/store_test.go b/internal/store/yaml/store_test.go index a34711db..9c5d5624 100644 --- a/internal/store/yaml/store_test.go +++ b/internal/store/yaml/store_test.go @@ -101,6 +101,36 @@ func TestYAMLTemplateRepo_Delete(t *testing.T) { assert.True(t, errors.Is(err, store.ErrNotFound)) } +// --- PolicyRepository --- + +func TestYAMLPolicyRepo_SaveAndFindByCode(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + + want := storetest.NewFullPolicy("vip") + require.NoError(t, repo.Save(ctx, want)) + + got, err := repo.FindByCode(ctx, "vip") + require.NoError(t, err) + assert.Equal(t, want.Code, got.Code) + assert.Equal(t, want.RequireToken, got.RequireToken) + assert.Equal(t, want.TokenSecret, got.TokenSecret) + assert.Equal(t, want.AllowIPs, got.AllowIPs) + assert.Equal(t, want.AllowedDomains, got.AllowedDomains) +} + +func TestYAMLPolicyRepo_Delete(t *testing.T) { + ctx := context.Background() + repo := newStore(t).Policies() + + require.NoError(t, repo.Save(ctx, storetest.NewFullPolicy("delete_me"))) + require.NoError(t, repo.Delete(ctx, "delete_me")) + + _, err := repo.FindByCode(ctx, "delete_me") + require.Error(t, err) + assert.True(t, errors.Is(err, store.ErrNotFound)) +} + // --- RecordingRepository --- func TestYAMLRecordingRepo_SaveAndFindByID(t *testing.T) {