package dbauthz_test

import (
	"testing"

	"github.com/google/uuid"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/stretchr/testify/require"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
)

// TestInsertCustomRoles verifies creating custom roles cannot escalate permissions.
func TestInsertCustomRoles(t *testing.T) {
	t.Parallel()

	userID := uuid.New()
	subjectFromRoles := func(roles rbac.ExpandableRoles) rbac.Subject {
		return rbac.Subject{
			FriendlyName: "Test user",
			ID:           userID.String(),
			Roles:        roles,
			Groups:       nil,
			Scope:        rbac.ScopeAll,
		}
	}

	canCreateCustomRole := rbac.Role{
		Identifier:  rbac.RoleIdentifier{Name: "can-assign"},
		DisplayName: "",
		Site: rbac.Permissions(map[string][]policy.Action{
			rbac.ResourceAssignRole.Type:    {policy.ActionRead},
			rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate},
		}),
	}

	merge := func(u ...interface{}) rbac.Roles {
		all := make([]rbac.Role, 0)
		for _, v := range u {
			switch t := v.(type) {
			case rbac.Role:
				all = append(all, t)
			case rbac.ExpandableRoles:
				all = append(all, must(t.Expand())...)
			case rbac.RoleIdentifier:
				all = append(all, must(rbac.RoleByName(t)))
			default:
				panic("unknown type")
			}
		}

		return all
	}

	orgID := uuid.New()

	testCases := []struct {
		name string

		subject rbac.ExpandableRoles

		// Perms to create on new custom role
		organizationID uuid.UUID
		site           []codersdk.Permission
		org            []codersdk.Permission
		user           []codersdk.Permission
		member         []codersdk.Permission
		errorContains  string
	}{
		{
			// No roles, so no assign role
			name:           "no-roles",
			organizationID: orgID,
			subject:        rbac.RoleIdentifiers{},
			errorContains:  "forbidden",
		},
		{
			// This works because the new role has 0 perms
			name:           "empty",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole),
		},
		{
			name:           "mixed-scopes",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleOwner()),
			site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			errorContains: "organization roles specify site or user permissions",
		},
		{
			name:           "invalid-action",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleOwner()),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				// Action does not go with resource
				codersdk.ResourceWorkspace: {codersdk.ActionViewInsights},
			}),
			errorContains: "invalid action",
		},
		{
			name:           "invalid-resource",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleOwner()),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				"foobar": {codersdk.ActionViewInsights},
			}),
			errorContains: "invalid resource",
		},
		{
			// Not allowing these at this time.
			name:           "negative-permission",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleOwner()),
			org: []codersdk.Permission{
				{
					Negate:       true,
					ResourceType: codersdk.ResourceWorkspace,
					Action:       codersdk.ActionRead,
				},
			},
			errorContains: "no negative permissions",
		},
		{
			name:           "wildcard", // not allowed
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleOwner()),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {"*"},
			}),
			errorContains: "no wildcard symbols",
		},
		// escalation checks
		{
			name:           "read-workspace-escalation",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			errorContains: "not allowed to grant this permission",
		},
		{
			name:           "read-workspace-outside-org",
			organizationID: uuid.New(),
			subject:        merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			errorContains: "not allowed to grant this permission",
		},
		{
			name: "user-escalation",
			// These roles do not grant user perms
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
			user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			errorContains: "organization roles specify site or user permissions",
		},
		{
			// Not allowing these at this time.
			name:           "member-permissions",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole),
			member: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
			errorContains: "non-system roles specify member permissions",
		},
		{
			name:           "site-escalation",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
			site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok!
			}),
			errorContains: "organization roles specify site or user permissions",
		},
		// ok!
		{
			name:           "read-workspace-template-admin",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
		},
		{
			name:           "read-workspace-in-org",
			organizationID: orgID,
			subject:        merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
			org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
				codersdk.ResourceWorkspace: {codersdk.ActionRead},
			}),
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			db, _ := dbtestutil.NewDB(t)
			rec := &coderdtest.RecordingAuthorizer{
				Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()),
			}
			az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer())

			subject := subjectFromRoles(tc.subject)
			ctx := testutil.Context(t, testutil.WaitMedium)
			ctx = dbauthz.As(ctx, subject)

			_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
				Name:              "test-role",
				DisplayName:       "",
				OrganizationID:    uuid.NullUUID{UUID: tc.organizationID, Valid: true},
				SitePermissions:   slice.List(tc.site, convertSDKPerm),
				OrgPermissions:    slice.List(tc.org, convertSDKPerm),
				UserPermissions:   slice.List(tc.user, convertSDKPerm),
				MemberPermissions: slice.List(tc.member, convertSDKPerm),
			})
			if tc.errorContains != "" {
				require.ErrorContains(t, err, tc.errorContains)
			} else {
				require.NoError(t, err)

				// Verify the role is fetched with the lookup filter.
				roles, err := az.CustomRoles(ctx, database.CustomRolesParams{
					LookupRoles: []database.NameOrganizationPair{
						{
							Name:           "test-role",
							OrganizationID: tc.organizationID,
						},
					},
					ExcludeOrgRoles: false,
					OrganizationID:  uuid.Nil,
				})
				require.NoError(t, err)
				require.Len(t, roles, 1)
			}
		})
	}
}

func convertSDKPerm(perm codersdk.Permission) database.CustomRolePermission {
	return database.CustomRolePermission{
		Negate:       perm.Negate,
		ResourceType: string(perm.ResourceType),
		Action:       policy.Action(perm.Action),
	}
}

func TestSystemRoles(t *testing.T) {
	t.Parallel()

	orgID := uuid.New()

	canManageOrgRoles := rbac.Role{
		Identifier:  rbac.RoleIdentifier{Name: "can-manage-org-roles"},
		DisplayName: "",
		Site: rbac.Permissions(map[string][]policy.Action{
			rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate},
		}),
	}

	canCreateSystem := rbac.Role{
		Identifier:  rbac.RoleIdentifier{Name: "can-create-system"},
		DisplayName: "",
		Site: rbac.Permissions(map[string][]policy.Action{
			rbac.ResourceSystem.Type: {policy.ActionCreate},
		}),
	}

	canUpdateSystem := rbac.Role{
		Identifier:  rbac.RoleIdentifier{Name: "can-update-system"},
		DisplayName: "",
		Site: rbac.Permissions(map[string][]policy.Action{
			rbac.ResourceSystem.Type: {policy.ActionUpdate},
		}),
	}

	userID := uuid.New()
	subjectNoSystemPerms := rbac.Subject{
		FriendlyName: "Test user",
		ID:           userID.String(),
		Roles:        rbac.Roles([]rbac.Role{canManageOrgRoles}),
		Groups:       nil,
		Scope:        rbac.ScopeAll,
	}
	subjectWithSystemCreatePerms := subjectNoSystemPerms
	subjectWithSystemCreatePerms.Roles = rbac.Roles([]rbac.Role{canManageOrgRoles, canCreateSystem})
	subjectWithSystemUpdatePerms := subjectNoSystemPerms
	subjectWithSystemUpdatePerms.Roles = rbac.Roles([]rbac.Role{canManageOrgRoles, canUpdateSystem})

	db, _ := dbtestutil.NewDB(t)
	rec := &coderdtest.RecordingAuthorizer{
		Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()),
	}
	az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer())

	t.Run("insert-requires-system-create", func(t *testing.T) {
		t.Parallel()

		insertParamsTemplate := database.InsertCustomRoleParams{
			Name: "",
			OrganizationID: uuid.NullUUID{
				UUID:  orgID,
				Valid: true,
			},
			SitePermissions:   database.CustomRolePermissions{},
			OrgPermissions:    database.CustomRolePermissions{},
			UserPermissions:   database.CustomRolePermissions{},
			MemberPermissions: database.CustomRolePermissions{},
			IsSystem:          true,
		}

		t.Run("deny-no-system-perms", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			insertParams := insertParamsTemplate
			insertParams.Name = "test-system-role-" + uuid.NewString()

			ctx = dbauthz.As(ctx, subjectNoSystemPerms)

			_, err := az.InsertCustomRole(ctx, insertParams)
			require.ErrorContains(t, err, "forbidden")
		})

		t.Run("deny-update-only", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			insertParams := insertParamsTemplate
			insertParams.Name = "test-system-role-" + uuid.NewString()

			ctx = dbauthz.As(ctx, subjectWithSystemUpdatePerms)

			_, err := az.InsertCustomRole(ctx, insertParams)
			require.ErrorContains(t, err, "forbidden")
		})

		t.Run("allow-create-only", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			insertParams := insertParamsTemplate
			insertParams.Name = "test-system-role-" + uuid.NewString()

			ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)

			_, err := az.InsertCustomRole(ctx, insertParams)
			require.NoError(t, err)
		})
	})

	t.Run("update-requires-system-update", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitMedium)
		ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)

		// Setup: create the role that we will attempt to update in
		// subtests. One role for all is fine as we are only testing
		// authz.
		role, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
			Name: "test-system-role-" + uuid.NewString(),
			OrganizationID: uuid.NullUUID{
				UUID:  orgID,
				Valid: true,
			},
			SitePermissions:   database.CustomRolePermissions{},
			OrgPermissions:    database.CustomRolePermissions{},
			UserPermissions:   database.CustomRolePermissions{},
			MemberPermissions: database.CustomRolePermissions{},
			IsSystem:          true,
		})
		require.NoError(t, err)

		// Use same params for all updates as we're only testing authz.
		updateParams := database.UpdateCustomRoleParams{
			Name: role.Name,
			OrganizationID: uuid.NullUUID{
				UUID:  orgID,
				Valid: true,
			},
			DisplayName:       "",
			SitePermissions:   database.CustomRolePermissions{},
			OrgPermissions:    database.CustomRolePermissions{},
			UserPermissions:   database.CustomRolePermissions{},
			MemberPermissions: database.CustomRolePermissions{},
		}

		t.Run("deny-no-system-perms", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			ctx = dbauthz.As(ctx, subjectNoSystemPerms)

			_, err := az.UpdateCustomRole(ctx, updateParams)
			require.ErrorContains(t, err, "forbidden")
		})

		t.Run("deny-create-only", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)

			_, err := az.UpdateCustomRole(ctx, updateParams)
			require.ErrorContains(t, err, "forbidden")
		})

		t.Run("allow-update-only", func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitMedium)
			ctx = dbauthz.As(ctx, subjectWithSystemUpdatePerms)

			_, err := az.UpdateCustomRole(ctx, updateParams)
			require.NoError(t, err)
		})
	})

	t.Run("allow-member-permissions", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitMedium)
		ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)

		_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
			Name: "test-system-role-member-perms",
			OrganizationID: uuid.NullUUID{
				UUID:  orgID,
				Valid: true,
			},
			SitePermissions: database.CustomRolePermissions{},
			OrgPermissions:  database.CustomRolePermissions{},
			UserPermissions: database.CustomRolePermissions{},
			MemberPermissions: database.CustomRolePermissions{
				{
					ResourceType: rbac.ResourceWorkspace.Type,
					Action:       policy.ActionRead,
				},
			},
			IsSystem: true,
		})
		require.NoError(t, err)
	})

	t.Run("allow-negative-permissions", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitMedium)
		ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)

		_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
			Name: "test-system-role-negative",
			OrganizationID: uuid.NullUUID{
				UUID:  orgID,
				Valid: true,
			},
			SitePermissions: database.CustomRolePermissions{},
			OrgPermissions: database.CustomRolePermissions{
				{
					Negate:       true,
					ResourceType: rbac.ResourceWorkspace.Type,
					Action:       policy.ActionShare,
				},
			},
			UserPermissions:   database.CustomRolePermissions{},
			MemberPermissions: database.CustomRolePermissions{},
			IsSystem:          true,
		})
		require.NoError(t, err)
	})
}
