package resources // exploitation_authorization.go — Autorisation d'Exploitation (AE) // // AEs are embedded inside AbstractResource (field ExploitationAuthorizations). // They are NOT a separate MongoDB collection — each resource document carries // its own AEs, just like it carries its Instances. // // # Visibility filtering // // When a resource is returned to a consumer peer the AE list is filtered: // - AllowedPeerIDs empty → public AE, visible to all peers. // - AllowedPeerIDs non-empty, contains requester → visible to that peer. // - AllowedPeerIDs non-empty, doesn't contain requester → stripped from response. // // The resource owner always sees all of their own AEs unfiltered. // // # Enforcement // // oc-schedulerd's validateWorkflowIntegrity calls CheckWorkflowAE (defined in // its own package to avoid circular imports) before launching any execution. // Violations emit PEER_BEHAVIOR_EVENT(BehaviorFraud) against the consumer peer // and cause the execution to be rejected. import ( "encoding/json" "fmt" "slices" "time" "cloud.o-forge.io/core/oc-lib/tools" ) // CouplingConstraint defines which resources must or must not co-exist in the // same workflow when the protected resource is included. type CouplingConstraint struct { // RequiredResourceIDs — ALL of these resource UUIDs must appear in the // workflow alongside the protected resource. RequiredResourceIDs []string `json:"required_resource_ids,omitempty" bson:"required_resource_ids,omitempty"` // ForbiddenResourceIDs — NONE of these resource UUIDs may appear in the // workflow alongside the protected resource. ForbiddenResourceIDs []string `json:"forbidden_resource_ids,omitempty" bson:"forbidden_resource_ids,omitempty"` } // ExploitationAuthorization (AE) is embedded in a resource and restricts how // the resource may be used by other consumer peers. // // It is stored as part of the resource document (bson embedded), not as a // separate collection. Create/update it by PATCHing the parent resource. type ExploitationAuthorization struct { // ID is a client-assigned UUID so individual AEs can be referenced. ID string `json:"id" bson:"id"` // Name is a human-readable label shown in the catalog detail view. Name string `json:"name,omitempty" bson:"name,omitempty"` // AllowedPeerIDs restricts which consumer peers may use the resource. // An empty list means any peer is allowed. AllowedPeerIDs []string `json:"allowed_peer_ids,omitempty" bson:"allowed_peer_ids,omitempty"` // AllowedWorkflowIDs restricts which workflow IDs may include the resource. // An empty list means any workflow is allowed. AllowedWorkflowIDs []string `json:"allowed_workflow_ids,omitempty" bson:"allowed_workflow_ids,omitempty"` // Coupling describes positive (required) and negative (forbidden) coupling. // Nil means no coupling constraint. Coupling *CouplingConstraint `json:"coupling,omitempty" bson:"coupling,omitempty"` // ValidFrom / ValidUntil define the active window. ValidFrom *time.Time `json:"valid_from,omitempty" bson:"valid_from,omitempty"` ValidUntil *time.Time `json:"valid_until,omitempty" bson:"valid_until,omitempty"` // IsRevoked allows instant revocation without deleting the AE from the resource. IsRevoked bool `json:"is_revoked" bson:"is_revoked"` } // IsVisibleTo returns true when this AE should be included in the response to // peerID. The resource owner (creatorID) always sees all AEs. func (ae *ExploitationAuthorization) IsVisibleTo(peerID, creatorID string) bool { if peerID == creatorID { return true // owner sees everything } return len(ae.AllowedPeerIDs) == 0 || slices.Contains(ae.AllowedPeerIDs, peerID) } // CheckAE evaluates this AE against the execution context and returns any // violations found. workflowResourceIDs is the set of all resource UUIDs in // the workflow; resourceID is the UUID of the resource this AE belongs to. func (ae *ExploitationAuthorization) CheckAE( resourceID, workflowID, consumerPeerID string, workflowResourceIDs map[string]struct{}, now time.Time, ) []AEViolation { var vs []AEViolation add := func(t AEViolationType, msg string) { vs = append(vs, AEViolation{AEID: ae.ID, ResourceID: resourceID, Type: t, Message: msg}) } if ae.IsRevoked { add(AEViolationRevoked, fmt.Sprintf("AE %s for resource %s is revoked", ae.ID, resourceID)) return vs } if ae.ValidUntil != nil && now.After(*ae.ValidUntil) { add(AEViolationExpired, fmt.Sprintf("AE %s for resource %s expired at %s", ae.ID, resourceID, ae.ValidUntil.Format(time.RFC3339))) return vs } if ae.ValidFrom != nil && now.Before(*ae.ValidFrom) { add(AEViolationNotYetValid, fmt.Sprintf("AE %s for resource %s not valid until %s", ae.ID, resourceID, ae.ValidFrom.Format(time.RFC3339))) return vs } if consumerPeerID != "" && len(ae.AllowedPeerIDs) > 0 { if !slices.Contains(ae.AllowedPeerIDs, consumerPeerID) { add(AEViolationPeerNotAllowed, fmt.Sprintf( "peer %s not allowed to use resource %s (AE %s)", consumerPeerID, resourceID, ae.ID)) } } if workflowID != "" && len(ae.AllowedWorkflowIDs) > 0 { if !slices.Contains(ae.AllowedWorkflowIDs, workflowID) { add(AEViolationWorkflowNotAllow, fmt.Sprintf( "workflow %s not in allowed-workflow list for resource %s (AE %s)", workflowID, resourceID, ae.ID)) } } if ae.Coupling != nil { for _, req := range ae.Coupling.RequiredResourceIDs { if _, ok := workflowResourceIDs[req]; !ok { add(AEViolationCouplingRequired, fmt.Sprintf( "resource %s requires %s to be present (AE %s)", resourceID, req, ae.ID)) } } for _, forb := range ae.Coupling.ForbiddenResourceIDs { if _, ok := workflowResourceIDs[forb]; ok { add(AEViolationCouplingForbid, fmt.Sprintf( "resource %s forbids co-use with %s (AE %s)", resourceID, forb, ae.ID)) } } } return vs } // ── Violation types ─────────────────────────────────────────────────────────── type AEViolationType string const ( AEViolationRevoked AEViolationType = "ae_revoked" AEViolationExpired AEViolationType = "ae_expired" AEViolationNotYetValid AEViolationType = "ae_not_yet_valid" AEViolationPeerNotAllowed AEViolationType = "ae_peer_not_allowed" AEViolationWorkflowNotAllow AEViolationType = "ae_workflow_not_allowed" AEViolationCouplingRequired AEViolationType = "ae_coupling_required" AEViolationCouplingForbid AEViolationType = "ae_coupling_forbidden" ) // AEViolation describes a single constraint that was not satisfied. type AEViolation struct { AEID string ResourceID string Type AEViolationType Message string } // ── NATS emit helper (uses tools only — no oclib circular import) ───────────── // EmitAEBehaviorReport emits a PEER_BEHAVIOR_EVENT(BehaviorFraud) for each // unique AE violation. Call this before rejecting the execution. func EmitAEBehaviorReport(consumerPeerID string, violations []AEViolation) { if consumerPeerID == "" || len(violations) == 0 { return } seen := map[string]struct{}{} for _, v := range violations { key := v.AEID + ":" + v.ResourceID if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} report := tools.PeerBehaviorReport{ ReporterApp: "oc-scheduler", TargetPeerID: consumerPeerID, Severity: tools.BehaviorFraud, Reason: fmt.Sprintf("AE violation (%s): %s", v.Type, v.Message), Evidence: v.AEID, At: time.Now().UTC(), } if b, err := json.Marshal(report); err == nil { tools.NewNATSCaller().SetNATSPub(tools.PEER_BEHAVIOR_EVENT, tools.NATSResponse{ FromApp: "oc-scheduler", Method: int(tools.PEER_BEHAVIOR_EVENT), Payload: b, }) } } } // OriginType qualifies where a resource instance comes from. type OriginType int const ( // OriginPeer: instance offered by a known network peer (default). OriginPeer OriginType = iota // OriginPublic: instance from a public registry (Docker Hub, HuggingFace, etc.). // No peer confirmation is needed; access is unrestricted. OriginPublic // OriginSelf: self-hosted instance with no third-party peer. OriginSelf ) // OriginMeta carries provenance information for a resource instance. type OriginMeta struct { Type OriginType `json:"origin_type" bson:"origin_type"` Ref string `json:"origin_ref,omitempty" bson:"origin_ref,omitempty"` // e.g. "docker.io/pytorch/pytorch:2.1" Verified bool `json:"origin_verified" bson:"origin_verified"` // manually vetted by an OC admin } // IsPeerless MUST NOT be used for authorization decisions. // Use ResourceInstance.IsPeerless() instead, which derives the property // from structural invariants rather than this self-declared field. // // This method is kept only for display/logging purposes. func (o OriginMeta) DeclaredPeerless() bool { return o.Type != OriginPeer }