// Copyright (c) 2026 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

package omni

import (
	"context"
	"fmt"

	"github.com/cosi-project/runtime/pkg/resource"
	"github.com/cosi-project/runtime/pkg/resource/meta"
	"github.com/cosi-project/runtime/pkg/state"
	"github.com/siderolabs/gen/xslices"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	authres "github.com/siderolabs/omni/client/pkg/omni/resources/auth"
	"github.com/siderolabs/omni/client/pkg/omni/resources/common"
	"github.com/siderolabs/omni/client/pkg/omni/resources/infra"
	"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
	"github.com/siderolabs/omni/client/pkg/omni/resources/siderolink"
	"github.com/siderolabs/omni/client/pkg/omni/resources/system"
	"github.com/siderolabs/omni/client/pkg/omni/resources/virtual"
	"github.com/siderolabs/omni/internal/backend/runtime/omni/infraprovider"
	"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
	"github.com/siderolabs/omni/internal/pkg/auth"
	"github.com/siderolabs/omni/internal/pkg/auth/accesspolicy"
	"github.com/siderolabs/omni/internal/pkg/auth/actor"
	"github.com/siderolabs/omni/internal/pkg/auth/role"
	"github.com/siderolabs/omni/internal/pkg/ctxstore"
)

// PublicResourceTypes is the set of resource types that are accessible without any authentication.
// These are intentionally public and must remain readable before auth or EULA acceptance.
var PublicResourceTypes = xslices.ToSet([]resource.Type{
	authres.AuthConfigType,
	authres.EulaAcceptanceType,
})

var (
	// clusterIDTypeSet is the set of resource types which have the related cluster's ID as their ID.
	clusterIDTypeSet = xslices.ToSet([]resource.Type{
		omni.ClusterBootstrapStatusType,
		omni.ClusterConfigVersionType,
		omni.ClusterDestroyStatusType,
		omni.ClusterEndpointType,
		omni.ClusterWorkloadProxyStatusType,
		omni.ClusterKubernetesNodesType,
		omni.ClusterTaintType,
		omni.ClusterType,
		omni.ClusterUUIDType,
		omni.ClusterSecretsType,
		omni.ClusterStatusType,
		omni.ClusterDiagnosticsType,
		omni.EtcdAuditResultType,
		omni.EtcdBackupStatusType,
		omni.EtcdManualBackupType,
		omni.ImportedClusterSecretsType,
		omni.ClusterSecretsRotationStatusType,
		omni.RotateTalosCAType,
		omni.SecretRotationType,
		omni.RotateKubernetesCAType,
		omni.ClusterKubernetesManifestsStatusType,
		omni.KubernetesStatusType,
		omni.KubernetesUpgradeStatusType,
		omni.KubernetesUpgradeManifestStatusType,
		omni.KubernetesNodeAuditResultType,
		omni.TalosUpgradeStatusType,
		omni.LoadBalancerConfigType,
		omni.LoadBalancerStatusType,
		omni.UpgradeRolloutType,
	})

	// clusterLabelTypeSet is the set of resource types which have the related cluster's ID as a label.
	clusterLabelTypeSet = xslices.ToSet([]resource.Type{
		omni.ClusterMachineConfigType,
		omni.ClusterMachineIdentityType,
		omni.ClusterMachineType,
		omni.ClusterMachineConfigPatchesType,
		omni.ClusterMachineConfigStatusType,
		omni.ClusterMachineTalosVersionType,
		omni.ClusterMachineStatusType,
		omni.ClusterMachineRequestStatusType,
		omni.ConfigPatchType,
		omni.ControlPlaneStatusType,
		omni.ExposedServiceType,
		omni.ImagePullRequestType,
		omni.ImagePullStatusType,
		omni.MachineSetType,
		omni.MachineSetStatusType,
		omni.MachineSetNodeType,
		omni.EtcdBackupType,
		omni.SchematicConfigurationType,
		omni.ExtensionsConfigurationType,
		omni.MachineUpgradeStatusType,
		omni.KernelArgsType,
		omni.KernelArgsStatusType,
		omni.MachineExtensionsStatusType,
		omni.MachineExtensionsType,
		omni.NodeForceDestroyRequestType, // cluster label is not strictly required to be present on this resource, except when the cluster access is granted via ACLs
		omni.DiscoveryAffiliateDeleteTaskType,
		omni.RedactedClusterMachineConfigType,
		omni.MachineConfigDiffType,
		omni.MachinePendingUpdatesType,
		omni.ClusterMachineSecretsType,
		omni.KubernetesManifestGroupType,
		infra.InfraMachineType,
		infra.InfraMachineStatusType,
	})

	// userManagedResourceTypeSet is the set of resource types that are managed by the user.
	userManagedResourceTypeSet = xslices.ToSet(common.UserManagedResourceTypes)
)

// authorizationValidationOptions returns the validation options responsible for all authorization checks.
//
// These include the regular checks on the user's Omni-wide role, as well as the ACLs that can authorize the user to perform additional actions on specific clusters.
func authorizationValidationOptions(st state.State) []validated.StateOption {
	return []validated.StateOption{
		validated.WithListValidations(
			func(ctx context.Context, kind resource.Kind, opt ...state.ListOption) error {
				var opts state.ListOptions

				for _, o := range opt {
					o(&opts)
				}

				if len(opts.LabelQueries) == 0 {
					return checkForKindAccess(ctx, st, state.List, kind, nil)
				}

				for _, query := range opts.LabelQueries {
					if err := checkForKindAccess(ctx, st, state.List, kind, query.Terms); err != nil {
						return err
					}
				}

				return nil
			},
		),
		validated.WithWatchKindValidations(
			func(ctx context.Context, kind resource.Kind, opt ...state.WatchKindOption) error {
				var opts state.WatchKindOptions

				for _, o := range opt {
					o(&opts)
				}

				if len(opts.LabelQueries) == 0 {
					return checkForKindAccess(ctx, st, state.Watch, kind, nil)
				}

				for _, query := range opts.LabelQueries {
					if err := checkForKindAccess(ctx, st, state.Watch, kind, query.Terms); err != nil {
						return err
					}
				}

				return nil
			},
		),
		validated.WithGetValidations(
			func(ctx context.Context, ptr resource.Pointer, res resource.Resource, _ ...state.GetOption) error {
				if res == nil {
					// resource does not exist, decide whether to return NotFound or PermissionDenied based on the pointer
					clusterID := clusterIDFromPointer(ptr)

					return checkForRole(ctx, st, state.Access{
						ResourceNamespace: ptr.Namespace(),
						ResourceType:      ptr.Type(),
						ResourceID:        ptr.ID(),
						Verb:              state.Get,
					}, clusterID, false)
				}

				clusterID := clusterIDFromMetadata(res.Metadata())

				return checkForRole(ctx, st, state.Access{
					ResourceNamespace: res.Metadata().Namespace(),
					ResourceType:      res.Metadata().Type(),
					ResourceID:        res.Metadata().ID(),
					Verb:              state.Get,
				}, clusterID, false)
			},
		),
		validated.WithWatchValidations(
			func(ctx context.Context, ptr resource.Pointer, _ ...state.WatchOption) error {
				// todo: watch validation here only works for resources that have same ID as Cluster type,
				// not for the ones that are related over a label.
				// we should improve this by checking/filtering the labels for the cluster.
				clusterID := clusterIDFromPointer(ptr)

				return checkForRole(ctx, st, state.Access{
					ResourceNamespace: ptr.Namespace(),
					ResourceType:      ptr.Type(),
					ResourceID:        ptr.ID(),
					Verb:              state.Watch,
				}, clusterID, false)
			},
		),
		validated.WithCreateValidations(
			func(ctx context.Context, res resource.Resource, _ ...state.CreateOption) error {
				clusterID := clusterIDFromMetadata(res.Metadata())

				return checkForRole(ctx, st, state.Access{
					ResourceNamespace: res.Metadata().Namespace(),
					ResourceType:      res.Metadata().Type(),
					ResourceID:        res.Metadata().ID(),
					Verb:              state.Create,
				}, clusterID, false)
			},
		),
		validated.WithUpdateValidations(
			func(ctx context.Context, existingRes resource.Resource, newRes resource.Resource, _ ...state.UpdateOption) error {
				if existingRes == nil {
					// resource does not exist, decide whether to return NotFound or PermissionDenied based on the pointer
					newClusterID := clusterIDFromMetadata(newRes.Metadata())

					return checkForRole(ctx, st, state.Access{
						ResourceNamespace: newRes.Metadata().Namespace(),
						ResourceType:      newRes.Metadata().Type(),
						ResourceID:        newRes.Metadata().ID(),
						Verb:              state.Update,
					}, newClusterID, false)
				}

				existingClusterID := clusterIDFromMetadata(existingRes.Metadata())

				if err := checkForRole(ctx, st, state.Access{
					ResourceNamespace: existingRes.Metadata().Namespace(),
					ResourceType:      existingRes.Metadata().Type(),
					ResourceID:        existingRes.Metadata().ID(),
					Verb:              state.Update,
				}, existingClusterID, false); err != nil {
					return err
				}

				newClusterID := clusterIDFromMetadata(newRes.Metadata())

				// if this is a cluster-related resource and the ID has not changed, we don't need to do the same check again
				if newClusterID != "" && existingClusterID == newClusterID {
					return nil
				}

				return checkForRole(ctx, st, state.Access{
					ResourceNamespace: newRes.Metadata().Namespace(),
					ResourceType:      newRes.Metadata().Type(),
					ResourceID:        newRes.Metadata().ID(),
					Verb:              state.Update,
				}, newClusterID, false)
			},
		),
		validated.WithDestroyValidations(
			func(ctx context.Context, ptr resource.Pointer, res resource.Resource, _ ...state.DestroyOption) error {
				if res == nil {
					// resource does not exist, decide whether to return NotFound or PermissionDenied based on the pointer
					clusterID := clusterIDFromPointer(ptr)

					return checkForRole(ctx, st, state.Access{
						ResourceNamespace: ptr.Namespace(),
						ResourceType:      ptr.Type(),
						ResourceID:        ptr.ID(),
						Verb:              state.Destroy,
					}, clusterID, false)
				}

				clusterID := clusterIDFromMetadata(res.Metadata())

				return checkForRole(ctx, st, state.Access{
					ResourceNamespace: res.Metadata().Namespace(),
					ResourceType:      res.Metadata().Type(),
					ResourceID:        res.Metadata().ID(),
					Verb:              state.Destroy,
				}, clusterID, false)
			},
		),
	}
}

func checkForRole(ctx context.Context, st state.State, access state.Access, clusterID resource.ID, requireAll bool) error {
	if actor.ContextIsInternalActor(ctx) {
		return nil
	}

	if requireAll {
		clusterID = "any"
	}

	if clusterID != "" {
		clusterRole, matchesAll, err := accesspolicy.RoleForCluster(ctx, clusterID, st)
		if err != nil {
			return err
		}

		if clusterRole != role.None && (!requireAll || matchesAll) {
			// override the role in the context with the computed role for this cluster
			ctx = ctxstore.WithValue(ctx, auth.RoleContextKey{Role: clusterRole})
		}
	}

	return filterAccess(ctx, access)
}

func checkForKindAccess(ctx context.Context, st state.State, verb state.Verb, kind resource.Kind, labelTerms []resource.LabelTerm) error {
	clusterID := ""
	requireAll := false

	if isClusterRelatedType(kind.Type()) {
		clusterID = clusterIDFromLabelTerms(labelTerms)

		requireAll = clusterID == ""
	}

	return checkForRole(ctx, st, state.Access{
		ResourceNamespace: kind.Namespace(),
		ResourceType:      kind.Type(),
		Verb:              verb,
	}, clusterID, requireAll)
}

func isClusterRelatedType(typ resource.Type) bool {
	_, ok := clusterIDTypeSet[typ]
	if ok {
		return true
	}

	_, ok = clusterLabelTypeSet[typ]

	return ok
}

func clusterIDFromMetadata(resMD *resource.Metadata) resource.ID {
	if _, ok := clusterIDTypeSet[resMD.Type()]; ok {
		return resMD.ID()
	}

	if _, ok := clusterLabelTypeSet[resMD.Type()]; ok {
		cluster, _ := resMD.Labels().Get(omni.LabelCluster)

		return cluster
	}

	return ""
}

func clusterIDFromLabelTerms(labelTerms []resource.LabelTerm) resource.ID {
	for _, term := range labelTerms {
		if term.Key == omni.LabelCluster && term.Op == resource.LabelOpEqual {
			return term.Value[0]
		}
	}

	return ""
}

func clusterIDFromPointer(ptr resource.Pointer) resource.ID {
	if _, ok := clusterIDTypeSet[ptr.Type()]; ok {
		return ptr.ID()
	}

	return ""
}

func verbToRole(verb state.Verb) role.Role {
	switch verb {
	case state.Create, state.Update, state.Destroy:
		return role.Operator
	case state.Get, state.List, state.Watch:
		return role.Reader
	default:
		panic(fmt.Sprintf("unknown verb: %v", verb))
	}
}

// filterAccess provides a filter to exclude some resources and operations from external sources.
//
//nolint:cyclop,gocyclo
func filterAccess(ctx context.Context, access state.Access) error {
	if actor.ContextIsInternalActor(ctx) {
		return nil
	}

	// check if the resource is an infra provider resource - if it is, the access is managed in infraprovider.State
	if infraprovider.IsInfraProviderResource(access.ResourceNamespace, access.ResourceType) {
		return nil
	}

	// Public resource types are accessible without any authentication or EULA acceptance.
	if _, ok := PublicResourceTypes[access.ResourceType]; ok {
		return filterAccessByType(access)
	}

	var err error

	// authentication and authorization checks
	switch access.ResourceType {
	case omni.MachineType, // infra provider needs to be able to read machines to find out force-deleted ones and deprovision them
		siderolink.APIConfigType,        // infra provider needs to be able to read connection params to join nodes to Omni
		siderolink.ConnectionParamsType: // infra provider needs to be able to read connection params to join nodes to Omni
		_, err = auth.CheckGRPC(ctx, auth.WithRole(role.InfraProvider))
	case
		omni.ClusterType,
		omni.ClusterBootstrapStatusType,
		omni.ClusterConfigVersionType,
		omni.ClusterDestroyStatusType,
		omni.ClusterEndpointType,
		omni.ClusterKubernetesNodesType,
		omni.ClusterMachineIdentityType,
		omni.ClusterMachineStatusType,
		omni.ClusterMachineRequestStatusType,
		omni.ClusterMachineTalosVersionType,
		omni.ClusterMachineType,
		omni.ClusterMachineConfigPatchesType,
		omni.ClusterMachineConfigStatusType,
		omni.ClusterStatusType,
		omni.ClusterDiagnosticsType,
		omni.ClusterUUIDType,
		omni.ClusterWorkloadProxyStatusType,
		omni.ClusterTaintType,
		omni.ClusterKubernetesManifestsStatusType,
		omni.ConfigPatchType,
		omni.ControlPlaneStatusType,
		omni.DiscoveryAffiliateDeleteTaskType,
		omni.KubernetesNodeAuditResultType,
		omni.ExposedServiceType,
		omni.EtcdBackupType,
		omni.EtcdBackupStatusType,
		omni.EtcdBackupStoreStatusType,
		omni.EtcdBackupOverallStatusType,
		omni.EtcdManualBackupType,
		omni.ImagePullRequestType,
		omni.ImagePullStatusType,
		omni.ImportedClusterSecretsType,
		omni.InfraMachineConfigType,
		omni.KubernetesStatusType,
		omni.KubernetesUpgradeManifestStatusType,
		omni.KubernetesUpgradeStatusType,
		omni.KubernetesManifestGroupType,
		omni.LoadBalancerConfigType,
		omni.LoadBalancerStatusType,
		omni.MachineLabelsType,
		omni.MachineSetType,
		omni.MachineSetDestroyStatusType,
		omni.MachineSetNodeType,
		omni.MachineRequestSetStatusType,
		omni.MachineRequestSetType,
		omni.MachineSetStatusType,
		omni.MaintenanceConfigStatusType,
		omni.NodeForceDestroyRequestType,
		omni.TalosUpgradeStatusType,
		omni.RedactedClusterMachineConfigType,
		omni.MachineConfigDiffType,
		siderolink.LinkType,
		siderolink.PendingMachineType,
		siderolink.LinkStatusType,
		siderolink.JoinTokenStatusType,
		siderolink.NodeUniqueTokenStatusType,
		siderolink.GRPCTunnelConfigType,
		omni.MachineClassType,
		omni.MachineExtensionsStatusType,
		omni.MachineExtensionsType,
		omni.MachineStatusType,
		omni.MachineStatusSnapshotType,
		omni.MachineStatusLinkType,
		omni.MachineConfigGenOptionsType,
		omni.MachinePendingUpdatesType,
		omni.SchematicType,
		omni.SchematicConfigurationType,
		omni.InfraProviderCombinedStatusType,
		omni.InstallationMediaConfigType,
		omni.ExtensionsConfigurationType,
		omni.MachineUpgradeStatusType,
		omni.KernelArgsType,
		omni.KernelArgsStatusType,
		omni.ClusterSecretsRotationStatusType,
		omni.RotateTalosCAType,
		omni.RotateKubernetesCAType,
		omni.UpgradeRolloutType,
		system.ResourceLabelsType[*omni.MachineStatus](),
		virtual.LabelsCompletionType,
		virtual.SBCConfigType,
		virtual.CloudPlatformConfigType,
		virtual.MetalPlatformConfigType,
		virtual.KubernetesUsageType:
		_, err = auth.CheckGRPC(ctx, auth.WithRole(verbToRole(access.Verb)))
	case
		meta.NamespaceType,
		meta.ResourceDefinitionType,
		omni.FeaturesConfigType,
		omni.TalosExtensionsType,
		omni.TalosVersionType,
		omni.KubernetesVersionType,
		omni.InstallationMediaType,
		omni.OngoingTaskType,
		omni.MachineStatusMetricsType,
		omni.NotificationType,
		omni.ClusterMetricsType,
		omni.ClusterStatusMetricsType,
		system.SysVersionType,
		virtual.AdvertisedEndpointsType,
		virtual.CurrentUserType,
		virtual.ClusterPermissionsType,
		virtual.PermissionsType:
		// allow access with just valid signature
		_, err = auth.CheckGRPC(ctx, auth.WithValidSignature(true))
	case // admin-only resources
		authres.IdentityType,
		authres.IdentityLastActiveType,
		authres.IdentityStatusType,
		authres.UserType,
		authres.ServiceAccountStatusType,
		authres.SAMLLabelRuleType,
		authres.AccessPolicyType,
		siderolink.JoinTokenType,
		siderolink.DefaultJoinTokenType,
		omni.EtcdBackupS3ConfType,
		infra.ProviderType,
		omni.InfraMachineBMCConfigType:
		_, err = auth.CheckGRPC(ctx, auth.WithRole(role.Admin))

		if err == nil && access.Verb == state.Create && access.ResourceType == siderolink.JoinTokenType {
			err = status.Error(codes.PermissionDenied, "only read, update and destroy access is permitted, create should be done via the management.CreateJoinToken API call")
		}

		// Restrict direct mutations on Identity and User resources — these must go through ManagementService gRPC endpoints.
		if err == nil && !access.Verb.Readonly() && (access.ResourceType == authres.IdentityType || access.ResourceType == authres.UserType) {
			err = status.Errorf(codes.PermissionDenied, "only read access is permitted on resource %v, mutations should be done via ManagementService API calls", access.ResourceType)
		}
	default:
		err = status.Error(codes.PermissionDenied, "no access is permitted")
	}

	if err != nil {
		return err
	}

	// authorization checks by access type
	return filterAccessByType(access)
}

// filterAccessByType provides a filter to exclude some resources and operations from external sources.
func filterAccessByType(access state.Access) error {
	// allow full access, these resources are managed on the client side
	if _, ok := userManagedResourceTypeSet[access.ResourceType]; ok {
		return nil
	}

	switch access.ResourceType {
	case authres.EulaAcceptanceType:
		// Allow read and create access. Update and destroy are forbidden — EULA acceptance is permanent.
		if access.Verb.Readonly() || access.Verb == state.Create {
			return nil
		}

		return status.Error(codes.PermissionDenied, "only read and create access is permitted for EULA acceptance")
	case siderolink.PendingMachineType,
		siderolink.LinkType:
		// Allow read, update and delete access
		// Update access is required for siderolink by rtestutils.Destroy[*siderolink.Link] call on integration tests
		if access.Verb.Readonly() || access.Verb == state.Update || access.Verb == state.Destroy {
			return nil
		}

		return status.Error(codes.PermissionDenied, "only read, update and delete access is permitted")
	case
		omni.ClusterBootstrapStatusType,
		omni.ClusterConfigVersionType,
		omni.ClusterDestroyStatusType,
		omni.ClusterEndpointType,
		omni.ClusterKubernetesNodesType,
		omni.ClusterMachineIdentityType,
		omni.ClusterMachineStatusType,
		omni.ClusterMachineRequestStatusType,
		omni.ClusterMachineTalosVersionType,
		omni.ClusterMachineType,
		omni.ClusterMachineConfigPatchesType,
		omni.ClusterMachineConfigStatusType,
		omni.ClusterSecretsRotationStatusType,
		omni.ClusterMetricsType,
		omni.ClusterStatusMetricsType,
		omni.ClusterStatusType,
		omni.ClusterDiagnosticsType,
		omni.ClusterTaintType,
		omni.ClusterUUIDType,
		omni.ClusterWorkloadProxyStatusType,
		omni.ControlPlaneStatusType,
		omni.ClusterKubernetesManifestsStatusType,
		omni.DiscoveryAffiliateDeleteTaskType,
		omni.KubernetesNodeAuditResultType,
		omni.ExposedServiceType,
		omni.EtcdBackupType,
		omni.EtcdBackupStatusType,
		omni.EtcdBackupOverallStatusType,
		omni.EtcdBackupStoreStatusType,
		omni.FeaturesConfigType,
		omni.ImagePullRequestType,
		omni.ImagePullStatusType,
		omni.InfraProviderCombinedStatusType,
		omni.KubernetesStatusType,
		omni.KubernetesUpgradeManifestStatusType,
		omni.KubernetesUpgradeStatusType,
		omni.LoadBalancerConfigType,
		omni.LoadBalancerStatusType,
		omni.MachineType,
		omni.MachineClassType,
		omni.MachineConfigGenOptionsType,
		omni.MachineSetDestroyStatusType,
		omni.MachineSetStatusType,
		omni.MachineStatusType,
		omni.MachineStatusLinkType,
		omni.MachineStatusSnapshotType,
		omni.MachinePendingUpdatesType,
		omni.MaintenanceConfigStatusType,
		omni.KubernetesVersionType,
		omni.TalosExtensionsType,
		omni.TalosVersionType,
		omni.TalosUpgradeStatusType,
		omni.InstallationMediaType,
		omni.InstallationMediaConfigType,
		omni.OngoingTaskType,
		omni.RedactedClusterMachineConfigType,
		omni.MachineConfigDiffType,
		omni.SchematicType,
		omni.MachineUpgradeStatusType,
		omni.KernelArgsStatusType,
		omni.SchematicConfigurationType,
		omni.MachineExtensionsStatusType,
		omni.MachineExtensionsType,
		omni.MachineStatusMetricsType,
		omni.NotificationType,
		omni.UpgradeRolloutType,
		authres.AuthConfigType,
		authres.IdentityLastActiveType,
		authres.IdentityStatusType,
		authres.ServiceAccountStatusType,
		siderolink.JoinTokenStatusType,
		siderolink.NodeUniqueTokenStatusType,
		siderolink.ConnectionParamsType,
		siderolink.LinkStatusType,
		siderolink.APIConfigType,
		system.SysVersionType,
		system.ResourceLabelsType[*omni.MachineStatus](),
		meta.NamespaceType,
		meta.ResourceDefinitionType,
		virtual.AdvertisedEndpointsType,
		virtual.CurrentUserType,
		virtual.SBCConfigType,
		virtual.CloudPlatformConfigType,
		virtual.MetalPlatformConfigType,
		virtual.PermissionsType,
		virtual.KubernetesUsageType,
		virtual.LabelsCompletionType,
		virtual.ClusterPermissionsType:
		// allow read access only. these resources are either managed by controllers or plugins (e.g., infra provider plugins)
		if access.Verb.Readonly() {
			return nil
		}

		return status.Error(codes.PermissionDenied, "only read access is permitted")
	default:
		return status.Error(codes.PermissionDenied, "access is not permitted")
	}
}
