Repository: juanfont/headscale

Issue:
[Bug] If an unknown user is added to the policy, SSH policies become empty
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

With a simple OIDC setup, I'm trying to preemptively add users to a group (before they exist from the headscale point of view).

This is a working policy file:
```jsonc
{
  "groups": {
   "group:example-infra": [
      "odyx@example.com",
    ],
  },
  "tagOwners": {
    "tag:vps": [
      "odyx@example.com"
    ],
  },
  "acls": [
    // Allow infra to hit all ports on all tailnet machines (autogroup:tagged)
    {
      "action": "accept",
      "src": [
        "group:example-infra",
      ],
      "dst": [
        "autogroup:tagged:*"
      ]
    },
  ],
  "ssh": [
    // Allow infra to ssh to tag:example-infra server as debian
    {
      "action": "accept",
      "src": [
        "group:poto-infra"
      ],
      "dst": [
        "tag:example-infra",
      ],
      "users": [
        "debian",
      ],
    },
  ],
}
```

With this, I get something along these lines on `…/debug/ssh` (where id:15 is not tagged, id:16 is)
```json
{
  "id:15 hostname:exo-ts-004 givenname:exo-ts-004": {
    "rules": null
  },
  "id:16 hostname:ik-ts-004 givenname:ik-ts-004": {
    "rules": [
      {
        "principals": [
          {
            "nodeIP": "100.95.204.138"
          },
          {
            "nodeIP": "100.96.46.136"
          },
          {
            "nodeIP": "fd7a:115c:a1e0:706f:3577:8580:42e5:d751"
          },
          {
            "nodeIP": "fd7a:115c:a1e0:706f:ec80:d63:1688:af87"
          }
        ],
        "sshUsers": {
          "debian": "debian"
        },
        "action": {
          "accept": true,
          "allowAgentForwarding": true,
          "allowLocalPortForwarding": true,
          "allowRemotePortForwarding": true
        }
      }
    ]
  }
}
```

If I push this patch on the policy (with "otheruser@example.com" existing on the IdP, but who has not logged yet)
```patch
diff --git i/policy.hujson w/policy.hujson
index ccd3a64..8505c75 100644
--- i/policy.hujson
+++ w/policy.hujson
@@ -2,6 +2,7 @@
   "groups": {
    "group:example-infra": [
       "odyx@example.com",
+      "otheruser@example.com",
     ],
   },
   "tagOwners": {
```
… then the SSH rules become:
```json
{
  "id:15 hostname:exo-ts-004 givenname:exo-ts-004": {
    "rules": null
  },
  "id:16 hostname:ik-ts-004 givenname:ik-ts-004": {
    "rules": null
  }
}
```

… de-facto breaking each and every SSH login.

### Expected Behavior

I'd expect unrecognized/unknown users to be just ignored in the policy processing (ideally with a warning in the logs), not silently break the whole SSH policy.

### Steps To Reproduce

1. start from a working state with a valid SSH policy and existing users
2. add an unknown user to a group in a `src` SSH policy

### Environment

```markdown
- OS: Debian 'trixie' 13.1
- Headscale version: 0.27.0
- Tailscale version: 1.90.6
```

### Runtime environment

- [ ] Headscale is behind a (reverse) proxy
- [ ] Headscale runs in a container

### Debug information

(None apply)

Hints from issue comments:
@nblock: Reproduced on 0.27.0, but not on 0.26.1.

Solution patch:
diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go
index 04c6cc0a..4eefee91 100644
--- a/hscontrol/db/db.go
+++ b/hscontrol/db/db.go
@@ -952,6 +952,41 @@ AND auth_key_id NOT IN (
 					return nil
 				},
 			},
+			{
+				// Drop all indices that are no longer in use and has existed.
+				// They potentially still present from broken migrations in the past.
+				// They should all be cleaned up by the db engine, but we are a bit
+				// conservative to ensure all our previous mess is cleaned up.
+				ID: "202511101554-drop-old-idx",
+				Migrate: func(tx *gorm.DB) error {
+					for _, oldIdx := range []struct{ name, table string }{
+						{"idx_namespaces_deleted_at", "namespaces"},
+						{"idx_routes_deleted_at", "routes"},
+						{"idx_shared_machines_deleted_at", "shared_machines"},
+					} {
+						err := tx.Migrator().DropIndex(oldIdx.table, oldIdx.name)
+						if err != nil {
+							log.Trace().
+								Str("index", oldIdx.name).
+								Str("table", oldIdx.table).
+								Err(err).
+								Msg("Error dropping old index, continuing...")
+						}
+					}
+
+					return nil
+				},
+				Rollback: func(tx *gorm.DB) error {
+					return nil
+				},
+			},
+
+			// Migrations **above** this points will be REMOVED in version **0.29.0**
+			// This is to clean up a lot of old migrations that is seldom used
+			// and carries a lot of technical debt.
+			// Any new migrations should be added after the comment below and follow
+			// the rules it sets out.
+
 			// From this point, the following rules must be followed:
 			// - NEVER use gorm.AutoMigrate, write the exact migration steps needed
 			// - AutoMigrate depends on the struct staying exactly the same, which it won't over time.
diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go
index bb7d089a..dd8e70c5 100644
--- a/hscontrol/policy/v2/filter.go
+++ b/hscontrol/policy/v2/filter.go
@@ -316,7 +316,6 @@ func (pol *Policy) compileSSHPolicy(
 		srcIPs, err := rule.Sources.Resolve(pol, users, nodes)
 		if err != nil {
 			log.Trace().Caller().Err(err).Msgf("SSH policy compilation failed resolving source ips for rule %+v", rule)
-			continue // Skip this rule if we can't resolve sources
 		}
 
 		if srcIPs == nil || len(srcIPs.Prefixes()) == 0 {



Test patch:
diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
index 388fefbc..d911e960 100644
--- a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
+++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
@@ -31,10 +31,15 @@ CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identif
 CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL;
 
 -- Create all the old tables we have had and ensure they are clean up.
-CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `namespaces` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
 CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`));
 CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`));
-CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `shared_machines` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
 CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`));
-CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `routes` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
+
+CREATE INDEX `idx_routes_deleted_at` ON `routes`(`deleted_at`);
+CREATE INDEX `idx_namespaces_deleted_at` ON `namespaces`(`deleted_at`);
+CREATE INDEX `idx_shared_machines_deleted_at` ON `shared_machines`(`deleted_at`);
+
 COMMIT;
diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go
index c016fa58..10f6bf0a 100644
--- a/hscontrol/policy/policy_test.go
+++ b/hscontrol/policy/policy_test.go
@@ -1353,6 +1353,55 @@ func TestSSHPolicyRules(t *testing.T) {
 				},
 			}},
 		},
+		{
+			name:       "2863-allow-predefined-missing-users",
+			targetNode: taggedClient,
+			peers:      types.Nodes{&nodeUser2},
+			policy: `{
+  "groups": {
+   "group:example-infra": [
+      "user2@",
+      "not-created-yet@",
+    ],
+  },
+  "tagOwners": {
+    "tag:client": [
+      "user2@"
+    ],
+  },
+  "ssh": [
+    // Allow infra to ssh to tag:example-infra server as debian
+    {
+      "action": "accept",
+      "src": [
+        "group:example-infra"
+      ],
+      "dst": [
+        "tag:client",
+      ],
+      "users": [
+        "debian",
+      ],
+    },
+  ],
+}`,
+			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
+				{
+					Principals: []*tailcfg.SSHPrincipal{
+						{NodeIP: "100.64.0.2"},
+					},
+					SSHUsers: map[string]string{
+						"debian": "debian",
+					},
+					Action: &tailcfg.SSHAction{
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
+					},
+				},
+			}},
+		},
 	}
 
 	for _, tt := range tests {