diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e49fb83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug Report +description: Report an Issue or Bug +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur? Please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: app-version + attributes: + label: App Version + description: What version are you running? Please be as specific as possible + placeholder: 1.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does this happen with? + description: You may select more than one. + multiple: true + options: + - Windows + - macOS + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4e771cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature Request +description: Suggest an idea or improvement for this project +title: "[Feature]: " +labels: ["feature"] +body: + - type: markdown + attributes: + value: | + Thank you for helping improve this project! Please provide as much detail as possible to help us understand your idea. + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + placeholder: I wish I could... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: I would like it to... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: I've also thought about... + validations: + required: false + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + placeholder: Any other notes or images... + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..5dfa839 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## Description + +Brief summary of changes and motivation. + +Fixes # (issue) + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing + +- [ ] Tests added/updated +- [ ] All tests pass locally +- [ ] Tested with both tenancy modes (if applicable) + +**Test Environment:** +- Windows: +- Mac OS: +- Linux: + +## Checklist + +- [ ] Code follows project style +- [ ] Self-reviewed code +- [ ] Updated documentation +- [ ] Backward compatible (or breaking change documented) +- [ ] No new warnings/errors + +## Additional Notes + +Any extra context or screenshots. \ No newline at end of file diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml new file mode 100644 index 0000000..3494bec --- /dev/null +++ b/.github/workflows/build_test.yml @@ -0,0 +1,44 @@ +name: Build and Test + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Verify Go installation + run: go version + + - name: Install dependencies + run: go mod download + + - name: Run tests + run: go test -v -short ./... + + - name: Run linter + uses: golangci/golangci-lint-action@v4 + with: + version: latest + + - name: Build application + run: CGO_ENABLED=1 go build -v ./cmd/server + + - name: Check if binary was created + run: | + if [ ! -f server ]; then + echo "❌ Binary not found!" + exit 1 + fi + echo "✅ Build successful" \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3a34752 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +mdazizulhakim.cse@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dd978af --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,187 @@ +# Contributing to IMS PocketBase BaaS Starter + +Thank you for considering contributing to IMS PocketBase BaaS Starter! This document provides guidelines and information for contributors. + +## Code of Conduct + +This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the existing issues to avoid duplicates. When creating a bug report, include: + +- **Clear title and description** +- **Steps to reproduce** the issue +- **Expected vs actual behavior** +- **Environment details** (Go version, PocketBase version, OS) +- **Code samples** that demonstrate the issue + +### Suggesting Enhancements + +Enhancement suggestions are welcome! Please provide: + +- **Clear title and detailed description** +- **Use case** explaining why this enhancement would be useful +- **Possible implementation** details if you have ideas + +### Pull Requests + +1. **Fork** the repository +2. **Create a feature branch** from `main` +3. **Make your changes** following our coding standards +4. **Add tests** for new functionality +5. **Ensure all tests pass** +6. **Update documentation** if needed +7. **Submit a pull request** + +## Development Setup + +```bash +# Clone your fork +git clone https://github.com/Innovix-Matrix-Systems/ims-pocketbase-baas-starter + +cd ims-pocketbase-baas-starter + +# Setup environment +make setup-env + +# Start development environment +make dev + +# Run tests +make test +``` + +## Coding Standards + +- Follow **Go standard coding conventions** +- Use **meaningful variable and function names** +- Add **type declarations** where possible +- Write **comprehensive tests** for new features +- Keep **backward compatibility** in mind + +### Code Style + +We use Go's standard formatting tools: + +```bash +# Format code +make format + +# Run linter +make lint +``` + +## Testing + +All contributions must include appropriate tests: + +```bash +# Run all tests +make test +``` + +### Writing Tests + +- Use Go's standard testing package +- Test both **happy path and edge cases** +- Mock external dependencies when appropriate + +Example test structure: +```go +func TestFeature(t *testing.T) { + // Setup + app := setupTestApp() + + // Test case + result, err := app.DoSomething() + + // Assertions + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if result != expectedResult { + t.Errorf("Expected %v, got %v", expectedResult, result) + } +} +``` + +## Database Migrations + +When making changes that require database schema modifications: + +1. Follow the [Database Migrations Guide](docs/migrations.md) +2. Create properly numbered migration files +3. Include both forward and rollback migrations +4. Test migrations thoroughly + +## Documentation + +- Update **README.md** for new features +- Add **inline documentation** for complex functions +- Include **usage examples** in comments +- Update **configuration examples** when needed + +## Commit Messages + +Use clear, descriptive commit messages: + +``` +feat: add support for custom middleware +fix: resolve authentication issue +docs: update migration instructions +test: add tests for RBAC system +refactor: improve error handling +``` + +Prefix types: +- `feat:` New features +- `fix:` Bug fixes +- `docs:` Documentation changes +- `test:` Test additions/changes +- `refactor:` Code refactoring +- `style:` Code style changes +- `chore:` Maintenance tasks + +## Review Process + +1. **Automated checks** must pass (tests, code style) +2. **Manual review** by maintainers +3. **Discussion** if changes are needed +4. **Approval** and merge + +## Docker Development + +For Docker-based development: + +```bash +# Build development image +make dev-build + +# Start development environment +make dev + +# View logs +make dev-logs + +# Clean up +make dev-clean +``` + +## Getting Help + +- **GitHub Issues**: For bugs and feature requests +- **GitHub Discussions**: For questions and general discussion +- **Email**: Contact maintainers directly for sensitive issues + +## Recognition + +Contributors will be acknowledged in: +- **CHANGELOG.md** for their contributions +- **README.md** contributors section +- **GitHub contributors** page + +Thank you for helping make IMS PocketBase BaaS Starter better! 🚀 \ No newline at end of file diff --git a/README.md b/README.md index a23a5ab..08b5854 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ A Backend-as-a-Service (BaaS) starter kit built with PocketBase Go framework, en 1. **Clone the repository** ```bash - git clone + git clone https://github.com/Innovix-Matrix-Systems/ims-pocketbase-baas-starter.git + cd ims-pocketbase-baas-starter ``` @@ -59,32 +60,16 @@ A Backend-as-a-Service (BaaS) starter kit built with PocketBase Go framework, en ## Makefile Commands -### Development Commands - -- `make dev` - Start development environment with hot reload -- `make dev-build` - Build development Docker image -- `make dev-logs` - Show development container logs -- `make dev-clean` - Clean development environment - -### Production Commands - -- `make build` - Build production Docker image -- `make start` - Start production containers -- `make stop` - Stop containers -- `make restart` - Restart containers -- `make down` - Stop and remove containers -- `make logs` - Show container logs -- `make clean` - Remove containers, networks, and images -- `make delete-data` - Remove containers, networks, images, and volumes - -### Utility Commands - -- `make help` - Show all available commands -- `make generate-key` - Generate encryption key -- `make setup-env` - Setup environment file -- `make test` - Run tests -- `make lint` - Run linter -- `make format` - Format Go code +| Development | Production | Utility | +|-------------|------------|---------| +| `dev` - Start dev environment | `build` - Build production image | `help` - Show all commands | +| `dev-build` - Build dev image | `start` - Start containers | `generate-key` - Generate encryption key | +| `dev-logs` - Show dev logs | `stop` - Stop containers | `setup-env` - Setup environment file | +| `dev-clean` - Clean dev env | `restart` - Restart containers | `test` - Run tests | +| `dev-data-clean` - Clean dev data | `down` - Stop and remove containers | `lint` - Run linter | +| `dev-start` - Alias for dev | `logs` - Show container logs | `format` - Format Go code | +| `dev-status` - Show dev container status | `clean` - Remove containers, networks, images | `status` - Show container status | +| | `clean-data` - Remove only volumes | `prod-start` - Alias for start | ## Environment Configuration @@ -150,8 +135,8 @@ For detailed information about database migrations and schema management, see th ### Default Super Admin -- Email: `admin@example.com` -- Password: `admin123456` +- Email: `superadmin@ims.com` +- Password: `superadmin123456` ## Project Structure @@ -181,13 +166,8 @@ For detailed information about database migrations and schema management, see th ## Contributing -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run tests: `make test` -5. Format code: `make format` -6. Submit a pull request +Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to contribute to this project. ## License -This project is licensed under the MIT License. +This project is licensed under the [MIT License](LICENSE.md). diff --git a/docs/README.md b/docs/README.md index d1451ca..edf78e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,13 +34,7 @@ Comprehensive guide for implementing and using authentication middleware: ## Contributing to Documentation -When adding new features or making significant changes: - -1. Update relevant documentation files -2. Add new documentation files as needed -3. Update this README with links to new docs -4. Ensure examples are tested and working -5. Keep documentation in sync with code changes +Please see our [Contributing Guide](../CONTRIBUTING.md) for details on how to contribute to this project's documentation. ## Documentation Standards diff --git a/docs/middleware.md b/docs/middleware.md index a8d570f..bb1108f 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -244,6 +244,186 @@ The middleware works the same in both environments, but consider: - Token storage security on the client side - Rate limiting for auth endpoints +## Permission Middleware + +The permission middleware extends the authentication system to provide permission-based access control for custom routes. It checks if an authenticated user has specific permissions before allowing access to protected resources. + +### Overview + +The permission middleware builds upon the existing RBAC (Role-Based Access Control) system where users can have: +- Direct permissions assigned to them +- Permissions inherited through roles + +### Basic Setup + +The permission middleware is located in `internal/middlewares/permission.go` and provides: + +- `PermissionMiddleware` struct for organizing permission functionality +- `NewPermissionMiddleware()` constructor function +- `RequirePermission()` method that returns a middleware function +- `HasPermission()` method for checking user permissions + +### Basic Usage with Single Permission + +```go +func RegisterCustomRoutes(e *core.ServeEvent) { + // Initialize middlewares + authMiddleware := middlewares.NewAuthMiddleware() + permMiddleware := middlewares.NewPermissionMiddleware() + + g := e.Router.Group("/api/v1") + + // Protected route requiring specific permission + g.GET("/admin/users", func(e *core.RequestEvent) error { + // First ensure user is authenticated + authFunc := authMiddleware.RequireAuthFunc() + if err := authFunc(e); err != nil { + return err + } + + // Then check for specific permission + permFunc := permMiddleware.RequirePermission("users.view") + if err := permFunc(e); err != nil { + return err + } + + // Handler logic for authorized users + return e.JSON(200, map[string]string{"message": "User list access granted"}) + }) +} +``` + +### Usage with Multiple Permissions (ANY Logic) + +```go +// User needs ANY of these permissions to access the route +g.POST("/api/content", func(e *core.RequestEvent) error { + // Authentication first + authFunc := authMiddleware.RequireAuthFunc() + if err := authFunc(e); err != nil { + return err + } + + // Permission check - user needs ANY of these permissions + permFunc := permMiddleware.RequirePermission("content.create", "content.admin", "content.manage") + if err := permFunc(e); err != nil { + return err + } + + // Handler logic + return e.JSON(200, map[string]string{"message": "Content creation access granted"}) +}) +``` + +### Integration with Route Groups + +```go +func RegisterAdminRoutes(e *core.ServeEvent) { + authMiddleware := middlewares.NewAuthMiddleware() + permMiddleware := middlewares.NewPermissionMiddleware() + + // Admin route group + adminGroup := e.Router.Group("/api/admin") + + // Apply authentication and admin permission to all routes in the group + adminGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Convert echo.Context to core.RequestEvent + e := c.(*core.RequestEvent) + + // Apply authentication + authFunc := authMiddleware.RequireAuthFunc() + if err := authFunc(e); err != nil { + return err + } + + // Apply admin permission check + permFunc := permMiddleware.RequirePermission("admin.access") + if err := permFunc(e); err != nil { + return err + } + + return next(c) + } + }) + + // All routes in this group now require authentication and admin.access permission + adminGroup.GET("/dashboard", func(e *core.RequestEvent) error { + return e.JSON(200, map[string]string{"message": "Admin dashboard"}) + }) + + adminGroup.GET("/settings", func(e *core.RequestEvent) error { + return e.JSON(200, map[string]string{"message": "Admin settings"}) + }) +} +``` + +### Permission Error Handling + +The permission middleware returns standard HTTP error responses: + +- **403 Forbidden**: Returned when user is authenticated but lacks required permissions +- **401 Unauthorized**: Returned when user is not authenticated (handled by auth middleware) + +Example error response for insufficient permissions: + +```json +{ + "code": 403, + "message": "You don't have permission to access this resource", + "data": {} +} +``` + +### Testing Permission Middleware + +#### 1. Test with User Having Required Permission + +```bash +# First, authenticate and get a token for a user with the required permission +curl -X POST http://localhost:8090/api/collections/users/auth-with-password \ + -H "Content-Type: application/json" \ + -d '{"identity": "admin@example.com", "password": "password"}' + +# Use the token to access protected resource +curl http://localhost:8090/api/v1/admin/users \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +# Should return: {"message": "User list access granted"} +``` + +#### 2. Test with User Lacking Required Permission + +```bash +# Authenticate as a user without the required permission +curl -X POST http://localhost:8090/api/collections/users/auth-with-password \ + -H "Content-Type: application/json" \ + -d '{"identity": "user@example.com", "password": "password"}' + +# Try to access protected resource +curl http://localhost:8090/api/v1/admin/users \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +# Should return: 403 Forbidden +``` + +### Permission System Requirements + +For the permission middleware to work, your PocketBase application should have: + +1. **Users Collection**: With authentication enabled +2. **Roles Collection**: For role-based permissions +3. **Permissions Collection**: Defining available permissions +4. **User-Role Relationships**: Users can have multiple roles +5. **Role-Permission Relationships**: Roles can have multiple permissions +6. **Direct User Permissions**: Users can have direct permissions without roles + +### Best Practices + +1. **Always Apply Authentication First**: Permission middleware should be used after authentication middleware +2. **Use Descriptive Permission Names**: Use clear, hierarchical permission names like `users.view`, `content.create` +3. **Group Related Routes**: Apply permissions at the route group level when possible +4. **Test Permission Scenarios**: Test with users having different permission combinations +5. **Admin Override**: Admin users typically bypass permission checks + ## Future Extensions The middleware is designed to be extensible. Future enhancements might include: diff --git a/docs/migrations.md b/docs/migrations.md index bcd4022..6b1181f 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -10,7 +10,7 @@ Our migration system uses PocketBase's built-in migration framework with an incr ### Initial Migration (0001_init.go) -The initial migration imports the complete database schema from `pb_schema.json` and sets up: +The initial migration imports the complete database schema from `0001_pb_schema.json` and sets up: - All base collections (users, roles, permissions) - System collections (\_superusers, \_authOrigins, etc.) @@ -32,13 +32,13 @@ For new collections or schema changes, we use targeted migrations that only affe internal/database/ ├── migrations/ │ ├── 0001_init.go # Initial schema and setup -│ ├── 0002_add_user_profiles.go # Example: User profile collections +│ ├── 0002_add_user_settings.go # User settings collections │ ├── 0003_add_audit_logs.go # Example: Audit logging │ └── utils.go # Migration helper functions ├── schema/ -│ ├── pb_schema.json # Complete initial schema -│ ├── 0002_user_profiles.json # New collections for migration 0002 -│ ├── 0003_audit_logs.json # New collections for migration 0003 +│ ├── 0001_pb_schema.json # Complete initial schema +│ ├── 0002_pb_schema.json # User settings collections for migration 0002 +│ ├── 0003_pb_schema.json # Example: Future schema additions │ └── README.md # Schema documentation └── seeders/ ├── rbac_seeder.go # Role-based access control seeding @@ -56,7 +56,7 @@ internal/database/ ### Step 2: Export Schema 1. Export only the new collections from PocketBase Admin UI -2. Save as `internal/database/schema/XXXX_description.json` +2. Save as `internal/database/schema/XXXX_pb_schema.json` 3. Use sequential numbering (0002, 0003, etc.) ### Step 3: Create Migration File @@ -80,7 +80,7 @@ import ( func init() { m.Register(func(app core.App) error { // Forward migration - schemaPath := filepath.Join("internal", "database", "schema", "0002_user_profiles.json") + schemaPath := filepath.Join("internal", "database", "schema", "0002_pb_schema.json") schemaData, err := os.ReadFile(schemaPath) if err != nil { return fmt.Errorf("failed to read schema file: %w", err) @@ -105,7 +105,7 @@ func init() { return nil }, func(app core.App) error { // Rollback migration - collectionsToDelete := []string{"user_profiles", "user_preferences"} + collectionsToDelete := []string{"settings", "user_settings"} for _, collectionName := range collectionsToDelete { collection, err := app.FindCollectionByNameOrId(collectionName) @@ -139,9 +139,9 @@ make dev-logs ### Naming Conventions -- **Migration files**: `XXXX_descriptive_name.go` (e.g., `0002_add_user_profiles.go`) -- **Schema files**: `XXXX_descriptive_name.json` (e.g., `0002_user_profiles.json`) -- **Collection names**: Use snake_case (e.g., `user_profiles`, `audit_logs`) +- **Migration files**: `XXXX_descriptive_name.go` (e.g., `0002_add_user_settings.go`) +- **Schema files**: `XXXX_pb_schema.json` (e.g., `0002_pb_schema.json`) +- **Collection names**: Use snake_case (e.g., `settings`, `user_settings`) ### Safety Guidelines diff --git a/go.mod b/go.mod index b183d17..db34e8b 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,9 @@ require ( github.com/pocketbase/pocketbase v0.29.0 ) -require github.com/stretchr/testify v1.10.0 // indirect - require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 031ab2c..1e30ad1 100644 --- a/go.sum +++ b/go.sum @@ -63,9 +63,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= @@ -97,8 +96,8 @@ golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= diff --git a/internal/app/app.go b/internal/app/app.go index 66186ea..ed3f8bd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,12 +11,15 @@ import ( "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/tools/hook" + "ims-pocketbase-baas-starter/internal" _ "ims-pocketbase-baas-starter/internal/database/migrations" //side effect migration load(from pocketbase) "ims-pocketbase-baas-starter/internal/middlewares" "ims-pocketbase-baas-starter/internal/routes" ) -func Run() { +// NewApp creates and configures a new PocketBase app instance +// This is useful for testing and for the main application +func NewApp() *pocketbase.PocketBase { app := pocketbase.New() // v0.29: register the official migratecmd plugin @@ -27,7 +30,6 @@ func Run() { }) app.OnServe().BindFunc(func(se *core.ServeEvent) error { - middleware := middlewares.NewAuthMiddleware() // Apply auth to specific PocketBase API endpoints @@ -36,30 +38,15 @@ func Run() { Func: func(e *core.RequestEvent) error { path := e.Request.URL.Path - // Define collections that require authentication - protectedCollections := []string{"users", "roles", "permissions"} // Add your collections here - - // Define endpoints to exclude from authentication - excludedPaths := []string{ - "/api/collections/users/auth-with-password", - "/api/collections/users/auth-refresh", - "/api/collections/users/request-password-reset", - "/api/collections/users/confirm-password-reset", - "/api/collections/users/request-verification", - "/api/collections/users/confirm-verification", - "/api/collections/users/request-email-change", - "/api/collections/users/confirm-email-change", - } - // Check if path should be excluded - for _, excludedPath := range excludedPaths { + for _, excludedPath := range internal.ExcludedPaths { if strings.HasPrefix(path, excludedPath) { return e.Next() // Skip auth for excluded paths } } // Check if it's a protected collection endpoint - for _, collection := range protectedCollections { + for _, collection := range internal.ProtectedCollections { collectionPath := "/api/collections/" + collection if strings.HasPrefix(path, collectionPath) { authFunc := middleware.RequireAuthFunc() @@ -83,6 +70,12 @@ func Run() { return se.Next() }) + return app +} + +func Run() { + app := NewApp() + if err := app.Start(); err != nil { log.Fatal(err) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..cfe96da --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "testing" + + "ims-pocketbase-baas-starter/internal" +) + +// TestAppCreation verifies that the app can be created without errors +func TestAppCreation(t *testing.T) { + pbApp := NewApp() + if pbApp == nil { + t.Fatal("Expected app.NewApp() to return a non-nil app") + } + + // Verify basic app components are initialized + if pbApp.Settings() == nil { + t.Fatal("Expected app to have settings configured") + } + + if pbApp.OnServe() == nil { + t.Fatal("Expected OnServe hook to be registered") + } +} + +// TestProtectedCollectionsConfiguration tests the protected collections are correctly defined +func TestProtectedCollectionsConfiguration(t *testing.T) { + expectedCollections := []string{"users", "roles", "permissions"} + + if len(internal.ProtectedCollections) != len(expectedCollections) { + t.Errorf("Expected %d protected collections, got %d", + len(expectedCollections), len(internal.ProtectedCollections)) + } + + // Verify each expected collection is present + collectionMap := make(map[string]bool) + for _, collection := range internal.ProtectedCollections { + collectionMap[collection] = true + } + + for _, expected := range expectedCollections { + if !collectionMap[expected] { + t.Errorf("Expected protected collection '%s' not found in %v", + expected, internal.ProtectedCollections) + } + } +} + +// TestMiddlewareRegistration tests that the middleware is properly registered +func TestMiddlewareRegistration(t *testing.T) { + pbApp := NewApp() + + // Get the OnServe hook + onServeHook := pbApp.OnServe() + if onServeHook == nil { + t.Fatal("Expected OnServe hook to be registered") + } + + // The hook should have at least one handler (our middleware) + // We can't directly access the handlers, but we can verify the hook exists + // and that creating the app doesn't panic +} diff --git a/internal/constant.go b/internal/constant.go new file mode 100644 index 0000000..8d2886a --- /dev/null +++ b/internal/constant.go @@ -0,0 +1,14 @@ +package internal + +var ProtectedCollections = []string{"users", "roles", "permissions"} + +var ExcludedPaths = []string{ + "/api/collections/users/auth-with-password", + "/api/collections/users/auth-refresh", + "/api/collections/users/request-password-reset", + "/api/collections/users/confirm-password-reset", + "/api/collections/users/request-verification", + "/api/collections/users/confirm-verification", + "/api/collections/users/request-email-change", + "/api/collections/users/confirm-email-change", +} diff --git a/internal/middlewares/.gitkeep b/internal/middlewares/.gitkeep deleted file mode 100644 index 0f175ed..0000000 --- a/internal/middlewares/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -middlewares folder \ No newline at end of file diff --git a/internal/middlewares/permission.go b/internal/middlewares/permission.go new file mode 100644 index 0000000..0543992 --- /dev/null +++ b/internal/middlewares/permission.go @@ -0,0 +1,148 @@ +package middlewares + +import ( + "log" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +// PermissionMiddleware provides permission-based middleware functionality +// It extends the authentication system to check for specific permissions +type PermissionMiddleware struct{} + +// NewPermissionMiddleware creates a new instance of PermissionMiddleware +func NewPermissionMiddleware() *PermissionMiddleware { + return &PermissionMiddleware{} +} + +// getUserPermissions extracts and processes all permissions for a user +// This includes direct permissions and those inherited from roles +// +// Parameters: +// - app: The PocketBase app instance +// - user: The authenticated user record +// +// Returns: +// - []string: Array of permission slugs the user has access to +func (m *PermissionMiddleware) getUserPermissions(app core.App, user *core.Record) []string { + // Extract and log permissions and roles arrays + userPermissions, _ := user.Get("permissions").([]string) + log.Printf("Direct permissions: %+v", userPermissions) + roles, _ := user.Get("roles").([]string) + + // fetch roles from collection + roleCollection, _ := app.FindCollectionByNameOrId("roles") + roleRecords, err := app.FindRecordsByIds(roleCollection, roles) + if err != nil { + log.Printf("Error fetching roles: %v", err) + } + for _, role := range roleRecords { + perms := role.GetStringSlice("permissions") + userPermissions = append(userPermissions, perms...) + } + + uniquePerms := make(map[string]struct{}) + for _, p := range userPermissions { + uniquePerms[p] = struct{}{} + } + userPermissions = userPermissions[:0] // reset slice + for p := range uniquePerms { + userPermissions = append(userPermissions, p) + } + + //fetch permissions form collection + permissionsCollection, _ := app.FindCollectionByNameOrId("permissions") + permissionsRecords, err := app.FindRecordsByIds(permissionsCollection, userPermissions) + if err != nil { + log.Printf("Error fetching permissions: %v", err) + } + + userPermissions = userPermissions[:0] // reset slice + //now from permission records we only need to get the array of permission slugs + for _, permission := range permissionsRecords { + permSlug := permission.GetString("slug") + userPermissions = append(userPermissions, permSlug) + } + + return userPermissions +} + +// HasPermission checks if a user has any of the specified permissions +// This checks both direct permissions assigned to the user and permissions from roles +// +// Parameters: +// - userPermissions: The authenticated user's permissions array slugs +// - permissions: String array of permission slugs to check +// +// Returns: +// - bool: True if the user has any of the specified permissions, false otherwise +func (m *PermissionMiddleware) HasPermission(userPermissions []string, permissions []string) bool { + if len(permissions) == 0 || len(userPermissions) == 0 { + return false + } + + // Create a map of user permissions for O(1) lookups + userPermMap := make(map[string]struct{}, len(userPermissions)) + for _, perm := range userPermissions { + userPermMap[perm] = struct{}{} + } + + // Check if any required permission exists in the user's permission map + for _, requiredPerm := range permissions { + if _, exists := userPermMap[requiredPerm]; exists { + return true + } + } + + return false +} + +// RequirePermission returns a middleware function that requires specific permissions +// This middleware checks if the authenticated user has any of the specified permissions +// +// Parameters: +// - permissions: String array of permission slugs to check +// +// Returns: +// - func(*core.RequestEvent) error: Middleware function for direct route use +// +// Example: +// +// permFunc := middleware.RequirePermission("resource.view") +// router.GET("/protected", authFunc, permFunc, handlerFunc) +func (m *PermissionMiddleware) RequirePermission(permissions ...string) func(*core.RequestEvent) error { + return func(e *core.RequestEvent) error { + // If no permissions are required, allow the request + if len(permissions) == 0 { + return nil + } + + // Extract the authenticated user from the request event + // The Auth field contains the authenticated user record + user := e.Auth + + // Get user permissions from the user record and roles + userPermissions := m.getUserPermissions(e.App, user) + + if user == nil { + // User not found - this should not happen if used after auth middleware + // but we handle it gracefully + return apis.NewForbiddenError("Authentication required", nil) + } + + // Check if the user is a superuser of pocketbase they will bypass this check + if user.IsSuperuser() { + return nil + } + + // Check if the user has any of the required permissions + if m.HasPermission(userPermissions, permissions) { + // User has permission, allow the request to proceed + return nil + } + + // User doesn't have the required permissions, return 403 Forbidden + return apis.NewForbiddenError("You don't have permission to access this resource", nil) + } +} diff --git a/internal/middlewares/permission_test.go b/internal/middlewares/permission_test.go new file mode 100644 index 0000000..3b6d269 --- /dev/null +++ b/internal/middlewares/permission_test.go @@ -0,0 +1,63 @@ +package middlewares + +import ( + "testing" +) + +// TestHasPermission tests the HasPermission function with various scenarios +func TestHasPermission(t *testing.T) { + pm := NewPermissionMiddleware() + + tests := []struct { + name string + userPermissions []string + checkPermissions []string + expected bool + }{ + { + name: "user has required permission", + userPermissions: []string{"user.create", "user.view"}, + checkPermissions: []string{"user.create"}, + expected: true, + }, + { + name: "user has one of multiple required permissions", + userPermissions: []string{"user.create", "user.view"}, + checkPermissions: []string{"user.delete", "user.view"}, + expected: true, + }, + { + name: "user doesn't have required permission", + userPermissions: []string{"user.create", "user.view"}, + checkPermissions: []string{"user.delete"}, + expected: false, + }, + { + name: "empty user permissions", + userPermissions: []string{}, + checkPermissions: []string{"user.create"}, + expected: false, + }, + { + name: "empty check permissions", + userPermissions: []string{"user.create"}, + checkPermissions: []string{}, + expected: false, + }, + { + name: "both empty", + userPermissions: []string{}, + checkPermissions: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pm.HasPermission(tt.userPermissions, tt.checkPermissions) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 3d169a6..9bdda71 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -7,7 +7,8 @@ import ( ) func RegisterCustom(e *core.ServeEvent) { - middleware := middlewares.NewAuthMiddleware() + authMiddleware := middlewares.NewAuthMiddleware() + permissionMiddleware := middlewares.NewPermissionMiddleware() g := e.Router.Group("/api/v1") @@ -19,12 +20,27 @@ func RegisterCustom(e *core.ServeEvent) { //auth protected route g.GET("/protected", func(e *core.RequestEvent) error { // Apply authentication middleware - authFunc := middleware.RequireAuthFunc() + authFunc := authMiddleware.RequireAuthFunc() if err := authFunc(e); err != nil { return err } - // Your protected handler logic return e.JSON(200, map[string]string{"msg": "You are authenticated!"}) }) + + //Permission protected route + g.GET("/permission-test", func(e *core.RequestEvent) error { + //apply auth middleware + authFunc := authMiddleware.RequireAuthFunc() + if err := authFunc(e); err != nil { + return err + } + // Apply permission middleware + permissionFunc := permissionMiddleware.RequirePermission("user.create") + if err := permissionFunc(e); err != nil { + return err + } + // Your protected handler logic + return e.JSON(200, map[string]string{"msg": "You have the User create permission!"}) + }) } diff --git a/makefile b/makefile index 3653e8d..b8d358c 100644 --- a/makefile +++ b/makefile @@ -84,6 +84,16 @@ test: @echo "Running tests..." go test ./... +test-cov: + @echo "Running tests with coverage report..." + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated at coverage.html" + +test-short: + @echo "Running short tests..." + go test -v -short ./... + lint: @echo "Running linter..." golangci-lint run